From 5c3ae37d52393551d11d5c0c63d73a9cb4b6c184 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 14 Aug 2026 10:01:00 -0700 Subject: [PATCH] Add high quality WebGPU renderer (#3388) --- app/build.gradle.kts | 4 + .../eu/kanade/domain/base/BasePreferences.kt | 2 + .../manga/components/MangaCoverDialog.kt | 49 + .../settings/screen/SettingsAdvancedScreen.kt | 4 + .../reader/settings/ReadingModePage.kt | 53 + .../data/coil/TachiyomiImageDecoder.kt | 30 +- .../eu/kanade/tachiyomi/data/coil/Utils.kt | 8 + .../tachiyomi/ui/reader/ReaderActivity.kt | 3 +- .../ui/reader/setting/ReaderPreferences.kt | 59 +- .../ui/reader/setting/ReadingMode.kt | 15 + .../ui/reader/viewer/webgpu/WebGpuConfig.kt | 180 +++ .../ui/reader/viewer/webgpu/WebGpuViewer.kt | 1272 +++++++++++++++++ .../viewer/webgpu/WebGpuViewerContinuous.kt | 26 + core/common/build.gradle.kts | 1 + gradle/libs.versions.toml | 6 + .../moko-resources/base/strings.xml | 23 + 16 files changed, 1731 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 985d81b06..845a719e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -292,6 +292,10 @@ dependencies { } implementation(libs.image.decoder) + implementation(libs.image.decoder2) + implementation(libs.webgpuviewer) + implementation(libs.kim) + // UI libraries implementation(libs.material) implementation(libs.flexibleAdapter) diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index d38c55b2b..5bffd41a3 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -33,6 +33,8 @@ class BasePreferences( PRIVATE(MR.strings.ext_installer_private, false), } + val highQualityRenderer: Preference = preferenceStore.getBoolean("pref_high_quality_renderer_key", false) + val displayProfile: Preference = preferenceStore.getString("pref_display_profile_key", "") val hardwareBitmapThreshold: Preference = preferenceStore.getInt( diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 6df044638..d675c2f30 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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 @@ -38,21 +39,31 @@ 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.WebGpuRenderer +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.size.Size +import eu.kanade.domain.base.BasePreferences 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.ImageDecoder2 +import eu.kanade.tachiyomi.data.coil.newDecoder import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView +import kotlinx.coroutines.runBlocking import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get @Composable fun MangaCoverDialog( @@ -64,6 +75,9 @@ fun MangaCoverDialog( onEditClick: ((EditCoverAction) -> Unit)?, onDismissRequest: () -> Unit, ) { + val useNewRenderer = Injekt.get().highQualityRenderer.get() + val view = LocalView.current + Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties( @@ -148,6 +162,41 @@ fun MangaCoverDialog( } }, ) { contentPadding -> + if (useNewRenderer) { + val state = ImageViewerState() + + state.dpi = view.resources.displayMetrics.densityDpi / 100f + + ImageRequest.Builder(view.context) + .data(manga) + .size(Size.ORIGINAL) + .memoryCachePolicy(CachePolicy.DISABLED) + .newDecoder(true) + .target { result -> + val res = (result as ImageDecoder2.DecodeResultImage).res + val page = runBlocking(WebGpuRenderer.dispatcher) { + ImagePage(res.image, res.width, res.height) + }.apply { + image?.backgroundColor = 0 + } + state.apply { + fetchPage = { index -> + if (index == 0) { + page + } else { + null + } + } + invalidate() + } + } + .build() + .let(view.context.imageLoader::enqueue) + + ImageViewer(state = state) + return@Scaffold + } + val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 31cbd6dac..d38e6f5e4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -346,6 +346,10 @@ object SettingsAdvancedScreen : SearchableSettings { 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 diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt index 59c645941..b4e3bf576 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode +import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.CheckboxItem @@ -22,6 +23,8 @@ import tachiyomi.presentation.core.components.SettingsChipRow import tachiyomi.presentation.core.components.SliderItem import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.collectAsState +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.text.NumberFormat @Composable @@ -40,6 +43,26 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } + val default = Injekt.get().defaultReadingMode.get() + val resolved = ReadingMode.fromPreference( + when { + readingMode == ReadingMode.DEFAULT -> default + else -> manga?.readingMode?.toInt() ?: default + }, + ) + if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { + val dualPageView by viewModel.preferences.dualPageView.collectAsState() + SettingsChipRow(MR.strings.pref_dual_page_view) { + ReaderPreferences.DualPageView.entries.map { + FilterChip( + selected = it == dualPageView, + onClick = { viewModel.preferences.dualPageView.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } + } + } + val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) } SettingsChipRow(MR.strings.rotation_type) { ReaderOrientation.entries.map { @@ -56,6 +79,9 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { WebtoonViewerSettings(viewModel) } else { PagerViewerSettings(viewModel) + if (viewer is WebGpuViewer) { + WebGpuViewerSettings(viewModel) + } } } @@ -233,3 +259,30 @@ private fun ColumnScope.TapZonesItems( } } } + +@Composable +private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { + HeadingItem(MR.strings.webgpu_viewer) + + val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState() + SettingsChipRow(MR.strings.pref_transition_animation) { + ReaderPreferences.TransitionAnimation.entries.map { + 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.map { + FilterChip( + selected = it == cutoutMode, + onClick = { viewModel.preferences.cutoutMode.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt index 84e3f0496..b5896aa62 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.coil import android.graphics.Bitmap +import coil3.Canvas +import coil3.Image import coil3.ImageLoader import coil3.asImage import coil3.decode.DecodeResult @@ -62,7 +64,9 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti class Factory : Decoder.Factory { override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? { - return if (options.customDecoder || isApplicable(result.source.source())) { + return if (options.newDecoder) { + ImageDecoder2(result.source, options) + } else if (options.customDecoder || isApplicable(result.source.source())) { TachiyomiImageDecoder(result.source, options) } else { null @@ -88,3 +92,27 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti var displayProfile: ByteArray? = null } } + +class ImageDecoder2(private val resources: ImageSource, private val options: Options) : Decoder { + + class DecodeResultImage(val res: ca.mpreg.imagedecoder.ImageDecoder.DecodeResult) : Image { + override val size: Long get() = res.image.capacity().toLong() + override val width: Int get() = res.width + override val height: Int get() = res.height + override val shareable: Boolean get() = true + override fun draw(canvas: Canvas) {} + } + + override suspend fun decode(): DecodeResult { + val source = resources.source() + + val decoder = ca.mpreg.imagedecoder.ImageDecoder.new(source.inputStream()) + + val res = decoder.decode() + + return DecodeResult( + image = DecodeResultImage(res), + isSampled = false, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt index 7a920bf39..9987af64d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt @@ -42,3 +42,11 @@ val Options.customDecoder: Boolean get() = getExtra(customDecoderKey) private val customDecoderKey = 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 351e5ca30..8a28949af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -78,6 +78,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer +import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.isNightMode import eu.kanade.tachiyomi.util.system.openInBrowser @@ -479,7 +480,7 @@ class ReaderActivity : BaseActivity() { onShare = ::shareChapter.takeIf { isHttpSource }, chapterNavigatorType = if (!verticalNavigator) { - if (state.viewer is R2LPagerViewer) { + if (state.viewer is R2LPagerViewer || (state.viewer as? WebGpuViewer)?.isReversed ?: false) { ChapterNavigatorType.HORIZONTAL_RTL } else { ChapterNavigatorType.HORIZONTAL_LTR diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 5c91991b6..e229d2cd2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -132,6 +132,11 @@ class ReaderPreferences( false, ) + val dualPageView: Preference = preferenceStore.getEnum( + "pref_dual_page_view", + DualPageView.NEVER, + ) + // endregion // region Color filter @@ -189,6 +194,15 @@ class ReaderPreferences( // endregion + // region WebGpu + + val transitionAnimation: Preference = + preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.DEFAULT) + + val cutoutMode: Preference = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID) + + // endregion + enum class FlashColor { BLACK, WHITE, @@ -201,8 +215,14 @@ 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), } @@ -213,6 +233,41 @@ class ReaderPreferences( LOWEST(47), } + enum class TransitionAnimation(val titleRes: StringResource) { + DEFAULT(MR.strings.transition_animation_default), + 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, + ), + } + + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt index 8c97ed818..ec13f9d0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt @@ -2,14 +2,19 @@ package eu.kanade.tachiyomi.ui.reader.setting import androidx.annotation.DrawableRes import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.reader.ReaderActivity 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 tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get enum class ReadingMode( val stringRes: StringResource, @@ -67,6 +72,16 @@ enum class ReadingMode( } fun toViewer(preference: Int?, activity: ReaderActivity): Viewer { + if (Injekt.get().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) + CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity) + DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference") + } + } return when (fromPreference(preference)) { LEFT_TO_RIGHT -> L2RPagerViewer(activity) RIGHT_TO_LEFT -> R2LPagerViewer(activity) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt new file mode 100644 index 000000000..99757cd05 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt @@ -0,0 +1,180 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.webgpu + +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 +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Configuration used by pager viewers. + */ +class WebGpuConfig( + private val viewer: WebGpuViewer, + scope: CoroutineScope, + readerPreferences: ReaderPreferences = Injekt.get(), +) : 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 + + var transitionAnimation = ReaderPreferences.TransitionAnimation.DEFAULT + private set + + var cutoutMode = ReaderPreferences.CutoutMode.AVOID + private set + + var dualPageView = ReaderPreferences.DualPageView.NEVER + 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() }, + ) + + readerPreferences.transitionAnimation + .register( + { transitionAnimation = it }, + { imagePropertyChangedListener?.invoke() }, + ) + + readerPreferences.cutoutMode + .register( + { cutoutMode = it }, + { imagePropertyChangedListener?.invoke() }, + ) + + readerPreferences.dualPageView + .register( + { dualPageView = it }, + { imagePropertyChangedListener?.invoke() }, + ) + } + + private fun zoomTypeFromPreference(value: Int) { + imageZoomType = when (value) { + // Auto + 1 -> if (viewer.isReversed) { + ReaderPageImageView.ZoomStartPosition.RIGHT + } else { + ReaderPageImageView.ZoomStartPosition.LEFT + } + // 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 if (viewer.isVertical) { + 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() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt new file mode 100644 index 000000000..4d0fa892b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt @@ -0,0 +1,1272 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.webgpu + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import androidx.core.graphics.createBitmap +import ca.mpreg.imagedecoder.ImageDecoder +import ca.mpreg.webgpuviewer.ImageView +import ca.mpreg.webgpuviewer.draw.Draw +import ca.mpreg.webgpuviewer.draw.clear +import ca.mpreg.webgpuviewer.draw.line +import ca.mpreg.webgpuviewer.renderer.Image +import ca.mpreg.webgpuviewer.renderer.WebGpuRenderer +import ca.mpreg.webgpuviewer.transition.TransitionBasic +import ca.mpreg.webgpuviewer.transition.TransitionCube +import ca.mpreg.webgpuviewer.transition.TransitionCubeOuter +import ca.mpreg.webgpuviewer.transition.TransitionFade +import ca.mpreg.webgpuviewer.transition.TransitionFadeWhite +import ca.mpreg.webgpuviewer.transition.TransitionFlipLeft +import ca.mpreg.webgpuviewer.transition.TransitionFlipRight +import ca.mpreg.webgpuviewer.transition.TransitionSphere +import ca.mpreg.webgpuviewer.transition.TransitionStackDown +import ca.mpreg.webgpuviewer.transition.TransitionStackLeft +import ca.mpreg.webgpuviewer.transition.TransitionStackRight +import ca.mpreg.webgpuviewer.transition.TransitionStackUp +import ca.mpreg.webgpuviewer.viewer.ImagePage +import de.stefan_oltmann.kim.Kim +import de.stefan_oltmann.kim.android.readMetadata +import de.stefan_oltmann.kim.format.tiff.constant.TiffTag +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +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.setting.ReaderPreferences +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TransitionAnimation +import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition +import eu.kanade.tachiyomi.ui.reader.viewer.Viewer +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.min + +open class WebGpuViewer( + val activity: ReaderActivity, + val isReversed: Boolean, + val isVertical: Boolean, + val pager: ImageView = ImageView(activity, isVertical = isVertical), +) : Viewer { + + open val isContinuous: Boolean = false + + private val scope = MainScope() + + // Dedicated thread for decode worker to avoid blocking Dispatchers.Default pool + private val decodeExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "WebGpuViewer-Decode").apply { isDaemon = true } + } + private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher() + + // Single lock for all page cache and queue operations + private val lock = Object() + + // Page cache - keyed by stable PageKey for O(1) lookup + private val pageCache = LinkedHashMap() + + // Decode queue - pages waiting to be decoded, processed LIFO (last = highest priority) + private val decodeQueue = ArrayDeque() + + // Stable key types for page identity - data classes provide correct equals/hashCode + private sealed class PageKey { + data class Reader(val chapterId: Long?, val index: Int) : PageKey() + data class Transition(val prevId: Long?, val nextId: Long?) : PageKey() + } + + private fun pageKey(page: ViewerPage): PageKey = when (page) { + is ViewerReaderPage -> PageKey.Reader(page.page.chapter.chapter.id, page.page.index) + is TransitionPage -> PageKey.Transition(page.prevChapter?.chapter?.id, page.nextChapter?.chapter?.id) + else -> PageKey.Transition(null, null) + } + + private fun findInCache(key: PageKey): ViewerPage? = pageCache[key] + + /** Check if a page is in the cache by identity. O(1) via key lookup. */ + private fun pageInCache(page: ViewerPage): Boolean = pageCache[pageKey(page)] === page + + /** + * Queue a page for decoding if not already queued/loading/decoded. + * If prioritize=true and page is already queued, moves it to front. + * Must be called while holding lock. + */ + private fun queueForDecode(page: ViewerPage, prioritize: Boolean = false) { + // Already has a decoded image + if (page.imagePage.isDecoded) return + + when (page.state) { + PageState.IDLE -> { + page.state = PageState.QUEUED + if (prioritize) { + decodeQueue.addLast(page) + } else { + decodeQueue.addFirst(page) + } + lock.notify() + } + + PageState.QUEUED -> { + // Already queued - move to front if prioritizing + if (prioritize && decodeQueue.remove(page)) { + decodeQueue.addLast(page) + } + } + + PageState.LOADING, PageState.DECODING -> { + // Already being processed + } + } + } + + init { + // Decode worker thread - processes pages from the queue + scope.launch(decodeDispatcher) { + try { + while (true) { + val page = synchronized(lock) { + while (decodeQueue.isEmpty()) { + lock.wait() + } + decodeQueue.removeLast().also { it.state = PageState.DECODING } + } + + // Verify page is still valid (not evicted and doesn't have a decoded image yet) + val shouldProcess = synchronized(lock) { + pageInCache(page) && page.state == PageState.DECODING && !page.imagePage.isDecoded + } + + if (!shouldProcess) { + synchronized(lock) { + if (pageInCache(page)) page.state = PageState.IDLE + } + continue + } + + try { + when (page) { + is ViewerReaderPage -> decodeReaderPage(page) + is TransitionPage -> createTransitionPage(page) + } + } catch (e: Exception) { + Log.e("WebGpuViewer", "Decode error: ${pageKey(page)}", e) + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + } + } + } catch (_: InterruptedException) { + // Normal shutdown + } catch (e: Exception) { + Log.e("WebGpuViewer", "Decode worker died", e) + } + } + } + + /** + * Configuration used by the pager, like allow taps, scale mode on images, page transitions... + */ + val config = WebGpuConfig(this, scope) + + var viewerChapters: ViewerChapters? = null + + val pages: List? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages + + @Volatile + var currentPage: ViewerPage? = null + + val preloadCount = 3 + open val cacheSize = 9 + + /** + * Page processing state + */ + enum class PageState { + IDLE, + QUEUED, + LOADING, + DECODING, + } + + /** + * Evicts the page farthest from reference. Must be called while holding lock. + * @param reference The page to use as reference (defaults to currentPage) + */ + private fun evictFarthestPage(reference: ViewerPage? = null) { + val current = reference ?: currentPage ?: return + val candidates = pageCache.values.filter { it !== current }.toMutableSet() + if (candidates.isEmpty()) return + + fun findNext(page: ViewerPage): ViewerPage? = when (page) { + is ViewerReaderPage -> { + val chapterId = page.page.chapter.chapter.id + val nextIndex = page.page.index + 1 + candidates.find { + it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == nextIndex + } ?: candidates.find { it is TransitionPage && it.prevChapter?.chapter?.id == chapterId } + } + + is TransitionPage -> { + val nextChapterId = page.nextChapter?.chapter?.id + candidates.find { + it is ViewerReaderPage && it.page.chapter.chapter.id == nextChapterId && it.page.index == 0 + } + } + + else -> null + } + + fun findPrev(page: ViewerPage): ViewerPage? = when (page) { + is ViewerReaderPage -> { + val chapterId = page.page.chapter.chapter.id + val prevIndex = page.page.index - 1 + candidates.find { + it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == prevIndex + } ?: candidates.find { it is TransitionPage && it.nextChapter?.chapter?.id == chapterId } + } + + is TransitionPage -> { + val prevChapterId = page.prevChapter?.chapter?.id + page.prevChapter?.pages?.lastIndex?.let { lastIndex -> + candidates.find { + it is ViewerReaderPage && it.page.chapter.chapter.id == prevChapterId && + it.page.index == lastIndex + } + } + } + + else -> null + } + + var farthest: ViewerPage? = null + var forward: ViewerPage? = current + var backward: ViewerPage? = current + + for (i in 0 until cacheSize) { + if (candidates.isEmpty()) break + forward = forward?.let { findNext(it) } + backward = backward?.let { findPrev(it) } + if (forward == null && backward == null) break + if (forward != null && candidates.remove(forward)) farthest = forward + if (backward != null && candidates.remove(backward)) farthest = backward + } + + val toRemove = candidates.firstOrNull() ?: farthest ?: return + + pageCache.remove(pageKey(toRemove)) + decodeQueue.remove(toRemove) + toRemove.state = PageState.IDLE + (toRemove as? ViewerReaderPage)?.spreadPage?.cleanup() + toRemove.imagePage.cleanup() + } + + /** + * Gets or creates a page. Thread-safe. + * @param referencePage The page to use as reference for eviction (defaults to currentPage) + */ + fun getPage(page: ReaderPage, referencePage: ViewerPage? = null): ViewerPage { + val key = PageKey.Reader(page.chapter.chapter.id, page.index) + return synchronized(lock) { + findInCache(key) ?: ViewerReaderPage(page).also { newPage -> + pageCache[key] = newPage + while (pageCache.size > cacheSize) { + evictFarthestPage(referencePage ?: newPage) + } + } + } + } + + fun getPage( + prevChapter: ReaderChapter?, + nextChapter: ReaderChapter?, + referencePage: ViewerPage? = null, + ): ViewerPage { + val key = PageKey.Transition(prevChapter?.chapter?.id, nextChapter?.chapter?.id) + return synchronized(lock) { + findInCache(key) ?: TransitionPage(prevChapter, nextChapter).also { newPage -> + pageCache[key] = newPage + while (pageCache.size > cacheSize) { + evictFarthestPage(referencePage ?: newPage) + } + } + } + } + + abstract class ViewerPage { + abstract val prevChapter: ReaderChapter? + abstract val nextChapter: ReaderChapter? + abstract val prev: ViewerPage? + abstract val next: ViewerPage? + + @Volatile + var state: PageState = PageState.IDLE + + @Volatile + open var imagePage: ImagePage = ImagePage.Dummy(400, 400) + } + + inner class TransitionPage(override val prevChapter: ReaderChapter?, override val nextChapter: ReaderChapter?) : + ViewerPage() { + override val prev: ViewerPage? + get() = prevChapter?.pages?.lastOrNull()?.let { getPage(it) } + + override val next: ViewerPage? + get() = nextChapter?.pages?.firstOrNull()?.let { getPage(it) } + } + + inner class ViewerReaderPage(val page: ReaderPage) : ViewerPage() { + /** Cached spread ImagePage when this page is the anchor of a dual-page spread */ + var spreadPage: ImagePage? = null + + override val prevChapter: ReaderChapter? + get() = when (page.chapter) { + viewerChapters?.currChapter -> viewerChapters?.prevChapter + viewerChapters?.nextChapter -> viewerChapters?.currChapter + else -> null + } + + override val nextChapter: ReaderChapter? + get() = when (page.chapter) { + viewerChapters?.currChapter -> viewerChapters?.nextChapter + viewerChapters?.prevChapter -> viewerChapters?.currChapter + else -> null + } + + override val prev: ViewerPage? + get() = page.chapter.pages?.let { pages -> + pages.getOrNull(page.index - 1)?.let { getPage(it) } ?: prevChapter?.let { prevChapter -> + if (prevChapter.state !is ReaderChapter.State.Loaded) { + scope.launch(Dispatchers.Default) { + activity.viewModel.preload(prevChapter) + currentPage?.let { preloadPages(it) } + } + } + if (config.alwaysShowChapterTransition) { + getPage(prevChapter, page.chapter) + } else { + prevChapter.pages?.lastOrNull()?.let { getPage(it) } + } + } ?: getPage(null, page.chapter) + } + + override val next: ViewerPage? + get() = page.chapter.pages?.let { pages -> + pages.getOrNull(page.index + 1)?.let { getPage(it) } ?: nextChapter?.let { nextChapter -> + if (nextChapter.state !is ReaderChapter.State.Loaded) { + scope.launch(Dispatchers.Default) { + activity.viewModel.preload(nextChapter) + currentPage?.let { preloadPages(it) } + } + } + if (config.alwaysShowChapterTransition) { + getPage(page.chapter, nextChapter) + } else { + nextChapter.pages?.firstOrNull()?.let { getPage(it) } + } + } ?: getPage(page.chapter, null) + } + } + + /** + * Check if dual page mode is currently active based on config and view dimensions. + * Dual page is never active for continuous (scrolling) viewers. + */ + private fun isDualPageMode(): Boolean { + if (isContinuous) return false + return when (config.dualPageView) { + ReaderPreferences.DualPageView.NEVER -> false + ReaderPreferences.DualPageView.ALWAYS -> true + ReaderPreferences.DualPageView.WIDE -> { + val width = pager.state.width + val height = pager.state.height + width > 0 && height > 0 && width.toFloat() / height > 1f + } + } + } + + /** + * Check if the given page can form a spread with the next page. + * Uses image.position to determine: anchor + partner = spread + * RTL: RIGHT is anchor, looks for LEFT on next + * LTR: LEFT is anchor, looks for RIGHT on next + */ + private fun canFormSpread(page: ViewerReaderPage): Boolean { + if (!isDualPageMode()) return false + val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT + val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT + if (page.imagePage.image?.position != anchorPosition) return false + val next = page.next as? ViewerReaderPage ?: return false + if (next.page.chapter != page.page.chapter) return false + return next.imagePage.image?.position == partnerPosition + } + + /** + * Get the anchor page for a spread. + * RTL: anchor is RIGHT, for LEFT page returns previous RIGHT + * LTR: anchor is LEFT, for RIGHT page returns previous LEFT + */ + private fun getSpreadAnchor(page: ViewerPage): ViewerPage { + if (!isDualPageMode()) return page + if (page !is ViewerReaderPage) return page + + val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT + val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT + + // If this is a partner page, check if previous is anchor + if (page.imagePage.image?.position == partnerPosition) { + val prev = page.prev as? ViewerReaderPage ?: return page + if (prev.page.chapter == page.page.chapter && prev.imagePage.image?.position == anchorPosition) { + return prev + } + } + + // This page is the anchor or standalone + return page + } + + /** + * Build an ImagePage for the given page, potentially combining with adjacent page for spread. + * RTL: RIGHT anchor + LEFT partner + * LTR: LEFT anchor + RIGHT partner + */ + private fun buildSpreadPage(page: ViewerPage): ImagePage { + // For TransitionPage, return its imagePage directly + if (page !is ViewerReaderPage) { + return page.imagePage + } + + val image = page.imagePage.image + + // Only form spreads in dual page mode + if (!isDualPageMode()) { + return page.imagePage + } + + val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT + val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT + + // Anchor pages look for partner on next page + if (image?.position == anchorPosition) { + val nextReaderPage = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } + val partnerImage = nextReaderPage?.imagePage?.image?.takeIf { it.position == partnerPosition } + + if (partnerImage != null) { + // Reuse existing spread if images match - preserves transform state + val existing = page.spreadPage + if (existing != null && existing.images.getOrNull(0) === image && + existing.images.getOrNull(1) === partnerImage + ) { + return existing + } + + // Create new spread: [anchor, partner] + val spread = ImagePage(image, partnerImage) + spread.ownsImages = false + page.spreadPage = spread + return spread + } + } + + // Single page or no spread partner - clear any cached spread + page.spreadPage = null + return page.imagePage + } + + init { + pager.state.apply { + fetchPage = fetch@{ index -> + val i = if (isReversed) -index else index + val current = currentPage ?: return@fetch null + + // For index 0, return the current spread + if (i == 0) { + return@fetch buildSpreadPage(getSpreadAnchor(current)) + } + + // Navigate by spreads from current + var page = current + val step = if (i > 0) 1 else -1 + repeat(abs(i)) { + page = nextPage(page, step) ?: return@fetch null + } + + return@fetch buildSpreadPage(page) + } + + onTap = { offset -> + when (config.navigator.getAction(PointF(offset.x, offset.y))) { + NavigationRegion.MENU -> activity.toggleMenu() + NavigationRegion.NEXT -> if (isReversed) moveToPrevious() else moveToNext() + NavigationRegion.PREV -> if (isReversed) moveToNext() else moveToPrevious() + NavigationRegion.RIGHT -> if (isReversed) moveLeft() else moveRight() + NavigationRegion.LEFT -> if (isReversed) moveRight() else moveLeft() + } + } + + onLongTap = { _ -> + if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) { + (currentPage as? ViewerReaderPage)?.let { activity.onPageLongTap(it.page) } + } + } + } + + config.imagePropertyChangedListener = { + pager.state.apply { + transition = when (config.transitionAnimation) { + TransitionAnimation.DEFAULT -> if (isVertical) TransitionBasic.Vertical else TransitionBasic + TransitionAnimation.FLIP_LEFT -> TransitionFlipLeft + TransitionAnimation.FLIP_RIGHT -> TransitionFlipRight + TransitionAnimation.STACK_LEFT -> TransitionStackLeft + TransitionAnimation.STACK_RIGHT -> TransitionStackRight + TransitionAnimation.STACK_UP -> TransitionStackUp + TransitionAnimation.STACK_DOWN -> TransitionStackDown + TransitionAnimation.SPHERE -> TransitionSphere + TransitionAnimation.CUBE_INSIDE -> TransitionCube + TransitionAnimation.CUBE_OUTSIDE -> TransitionCubeOuter + TransitionAnimation.FADE -> TransitionFade + TransitionAnimation.FADE_WHITE -> TransitionFadeWhite + } + + when (config.cutoutMode) { + ReaderPreferences.CutoutMode.IGNORE -> avoidCutout = false + ReaderPreferences.CutoutMode.AVOID -> { + avoidCutout = true + alwaysAvoidCutout = false + } + + ReaderPreferences.CutoutMode.SHIFT -> { + avoidCutout = true + alwaysAvoidCutout = true + } + } + } + + synchronized(lock) { + decodeQueue.clear() + pageCache.values.forEach { + it.state = PageState.IDLE + (it as? ViewerReaderPage)?.spreadPage?.cleanup() + it.imagePage.cleanup() + } + pageCache.clear() + + currentPage = (currentPage as? ViewerReaderPage)?.page?.let { getPage(it) } + ?: (currentPage as? TransitionPage)?.let { + getPage(it.prevChapter, it.nextChapter) + } + + currentPage?.let { preloadPages(it) } + } + + pager.state.invalidate() + } + + config.navigationModeChangedListener = { + val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay + activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) + } + } + + override fun destroy() { + // Cancel scope first to stop any new operations + scope.cancel() + + // Shutdown decode executor with interrupt to wake up the worker from wait() + decodeExecutor.shutdownNow() + decodeDispatcher.close() + + // Now clean up pages (cleanup() launches fire-and-forget coroutines on Dispatchers.Default) + synchronized(lock) { + decodeQueue.clear() + pageCache.values.forEach { + it.state = PageState.IDLE + (it as? ViewerReaderPage)?.spreadPage?.cleanup() + it.imagePage.cleanup() + } + pageCache.clear() + // Notify in case worker is waiting (though it should be interrupted) + lock.notifyAll() + } + } + + /** + * Returns the view this viewer uses. + */ + override fun getView(): View = pager + + /** + * Start loading a page and set up listener to re-queue when ready. + * Called when decode worker encounters a page that isn't downloaded yet. + */ + private fun startPageLoad(page: ViewerReaderPage) { + val loader = page.page.chapter.pageLoader ?: run { + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + return + } + + // If page is already ready, just re-queue immediately + if (page.page.status == Page.State.Ready) { + synchronized(lock) { + if (pageInCache(page) && !page.imagePage.isDecoded) { + page.state = PageState.IDLE + queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false) + } else if (pageInCache(page)) { + page.state = PageState.IDLE + } + } + return + } + + // Transition to LOADING state + synchronized(lock) { + if (!pageInCache(page)) return + page.state = PageState.LOADING + } + + // Start the download + if (page.page.status == Page.State.Queue) { + scope.launch(Dispatchers.IO) { + loader.loadPage(page.page) + } + } + + // Set up progress indicator and re-queue when ready + scope.launch { + try { + val downloadProgressJob = launch { + page.page.progressFlow.collect { value -> + // Check if page was evicted or already decoded + synchronized(lock) { + if (!pageInCache(page) || page.imagePage !is ImagePage.Dummy) return@collect + } + + if (page.imagePage.image == null) { + page.imagePage = ImagePage.drawable(400, 400).apply { + WebGpuRenderer.withContext { + this@apply.texture?.let { texture -> + Draw.submit { encoder -> + clear(encoder, texture, 0x00000000) + line(encoder, texture, 0.1f, 0.5f, 0.9f, 0.5f, 0xFF101010.toInt(), 30f) + } + } + } + } + } else { + WebGpuRenderer.withContext { + (page.imagePage as ImagePage.Draw?)?.texture?.let { texture -> + Draw.submit { encoder -> + val x2 = 0.1f + (value / 100f) * 0.8f + line(encoder, texture, 0.1f, 0.5f, x2, 0.5f, 0xFFFFFFFF.toInt(), 20f) + } + } + pager.state.invalidate() + } + } + } + } + + page.page.statusFlow.takeWhile { state -> + when (state) { + Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true + is Page.State.Error -> { + Log.e("WebGpuViewer", "Page load error: ${state.error}") + false + } + + Page.State.Ready -> false + } + }.collect {} + + downloadProgressJob.cancel() + + // Re-queue for decoding if ready + synchronized(lock) { + if (pageInCache(page) && page.state == PageState.LOADING) { + page.state = PageState.IDLE + if (page.page.status == Page.State.Ready && !page.imagePage.isDecoded) { + queueForDecode( + page, + prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false, + ) + } + } + } + } catch (e: Exception) { + Log.e("WebGpuViewer", "startPageLoad error", e) + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + } + } + } + + private suspend fun decodeReaderPage(page: ViewerReaderPage) { + // If page isn't downloaded yet, start loading + if (page.page.status != Page.State.Ready) { + startPageLoad(page) + return + } + + val stream = page.page.stream?.invoke() ?: run { + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + return + } + + var imagePage: ImagePage? = null + try { + stream.use { input -> + // Check if still valid before decoding (not evicted and doesn't have decoded image yet) + synchronized(lock) { + if (!pageInCache(page) || page.imagePage.isDecoded) { + if (pageInCache(page)) page.state = PageState.IDLE + return + } + } + + // Buffer file to detect spread position tag, then decode. + // When not in dual page mode, skip Kim entirely. + val bytes = if (isDualPageMode()) input.readBytes() else null + + val position = if (bytes != null) { + val tag = Kim.readMetadata(bytes.inputStream(), bytes.size.toLong()) + ?.findStringValue(TiffTag.TIFF_TAG_PAGE_NAME) + when (tag) { + "Left" -> Image.Position.LEFT + "Right" -> Image.Position.RIGHT + null -> if (isReversed) { // TODO: heuristics, use image size + if (page.page.index % 2 == 0) Image.Position.LEFT else Image.Position.RIGHT + } else { + if (page.page.index % 2 == 0) Image.Position.RIGHT else Image.Position.LEFT + } + + else -> Image.Position.SINGLE + } + } else { + Image.Position.SINGLE + } + + val dec = ImageDecoder.new(bytes?.inputStream() ?: input) + val pageCount = dec.pages + + if (pageCount == 0) { + Log.e("WebGpuViewer", "decodeReaderPage: no frames decoded") + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + return + } + + // Decode first frame immediately; defer rest only if animated + val firstFrame = dec.decodeNext() + + // For first frame, create Image with trim in single GPU context switch + val trimColors = if (config.imageCropBorders) { + listOf( + floatArrayOf(1f, 1f, 1f), + floatArrayOf(0f, 0f, 0f), + ) + } else { + null + } + + val backgroundColor = when { + config.automaticBackground -> null + config.theme == 0 -> 0xFFFFFFFF.toInt() + config.theme == 1 -> 0xFF000000.toInt() + config.theme == 2 -> 0xFF808080.toInt() + else -> 0xFF000000.toInt() + } + + val firstImage = Image.createWithTrim( + firstFrame.image, + firstFrame.width, + firstFrame.height, + createMipMaps = true, + trimColors = trimColors, + trimThreshold = 0.15f, + backgroundColor = backgroundColor, + ) + + // Set position for dual page spreads based on reading direction: + // RTL (isReversed): Cover on LEFT, even=LEFT, odd=RIGHT + // LTR (!isReversed): Cover on RIGHT, even=RIGHT, odd=LEFT + firstImage.position = position + + // Create ImagePage early so its cleanup handles all frames + imagePage = ImagePage(firstImage) + + // Create remaining frames for animation (only for animated images) + if (pageCount > 1) { + val frames = ArrayList>(pageCount) + frames.add(Pair(firstImage, firstFrame.duration)) + for (i in 1 until pageCount) { + val frame = dec.decodeNext() + frames.add(Pair(Image(frame.image, frame.width, frame.height), frame.duration)) + } + imagePage.startAnimationLoop(frames) { + if (currentPage === page) pager.state.invalidate() + } + } + + synchronized(lock) { + if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) { + val oldImagePage = page.imagePage + page.imagePage = imagePage!! + imagePage = null + page.state = PageState.IDLE + if (oldImagePage !is ImagePage.Dummy) { + oldImagePage.cleanup() + } + applyWideZoomIfNeeded(page) + pager.state.invalidate() + } else { + if (pageInCache(page)) page.state = PageState.IDLE + } + } + } + } catch (e: Exception) { + Log.e("WebGpuViewer", "decodeReaderPage error", e) + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + } finally { + imagePage?.cleanup() + } + } + + private suspend fun createTransitionPage(page: TransitionPage) { + try { + // Check if still valid + synchronized(lock) { + if (!pageInCache(page) || page.imagePage.isDecoded) { + if (pageInCache(page)) page.state = PageState.IDLE + return + } + } + + val bitmap = createBitmap(pager.state.width, pager.state.height) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.BLACK) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 48f + textAlign = Paint.Align.CENTER + } + + val maxWidth = bitmap.width * 0.8f + val lineHeight = 48f + + fun wrapText(text: String): List { + val words = text.split(" ") + val lines = mutableListOf() + var currentLine = StringBuilder() + for (word in words) { + val testLine = if (currentLine.isEmpty()) word else "$currentLine $word" + if (paint.measureText(testLine) <= maxWidth) { + currentLine = StringBuilder(testLine) + } else { + if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) + currentLine = StringBuilder(word) + } + } + if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) + return lines + } + + val lines = mutableListOf>() + page.prevChapter?.chapter?.let { chapter -> + lines.add(Pair("Previous:", lineHeight)) + wrapText(chapter.name).forEach { lines.add(Pair(it, lineHeight)) } + page.nextChapter?.chapter?.let { lines.add(Pair("", lineHeight)) } + } + page.nextChapter?.chapter?.let { chapter -> + lines.add(Pair("Next:", lineHeight)) + wrapText(chapter.name).forEach { lines.add(Pair(it, lineHeight)) } + } + + val x = bitmap.width / 2f + var y = bitmap.height / 2f - lines.sumOf { it.second.toDouble() }.toFloat() / 2 + lines.forEach { + canvas.drawText(it.first, x, y + it.second, paint) + y += it.second + } + + val imagePage = ImagePage(bitmap, createMipMaps = false) + imagePage.image?.position = Image.Position.SINGLE + + synchronized(lock) { + if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) { + val oldImagePage = page.imagePage + page.imagePage = imagePage + page.state = PageState.IDLE + if (oldImagePage !is ImagePage.Dummy) { + oldImagePage.cleanup() + } + pager.state.invalidate() + } else { + if (pageInCache(page)) page.state = PageState.IDLE + imagePage.cleanup() + } + } + } catch (e: Exception) { + Log.e("WebGpuViewer", "createTransitionPage error", e) + synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + } + } + + private fun applyWideZoomIfNeeded(page: ViewerReaderPage) { + if (!config.landscapeZoom) return + val imagePage = page.imagePage + val image = imagePage.image ?: return + if (image.position != Image.Position.SINGLE) return + + val screenW = pager.state.width + val screenH = pager.state.height + if (screenW <= 0 || screenH <= 0) return + + // Wide page: half the image width is wider than the screen aspect ratio + if (image.width.toFloat() / image.height <= 2f * screenW.toFloat() / screenH) return + + // Scale to fit half the image width to the full screen width + val wideScale = screenW.toFloat() / (image.width / 2f) + + val halfOffset = (image.width / 4f) / screenW + val startX = when (config.imageZoomType) { + ZoomStartPosition.LEFT -> halfOffset + ZoomStartPosition.RIGHT -> -halfOffset + ZoomStartPosition.CENTER -> 0f + } + + imagePage.homeScaleOverride = wideScale + imagePage.homeXOverride = startX + imagePage.scale = wideScale + imagePage.x = startX + + imagePage.y = run { + val cutoutTopPx = pager.state.cutoutTopPx + if (cutoutTopPx <= 0f) return@run 0f + val trimTop = image.trim?.top ?: 0 + val imageOnScreen = image.height * wideScale + val imageTopY = (screenH - imageOnScreen) / 2f + val trimTopY = imageTopY + trimTop * wideScale + if (trimTopY < cutoutTopPx) (cutoutTopPx - trimTopY) / (wideScale * screenH) else 0f + } + } + + /** + * Queue a page for decoding. If prioritize=true, moves existing queued page to front. + */ + protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) { + synchronized(lock) { + val cachedPage = findInCache(pageKey(page)) ?: return + queueForDecode(cachedPage, prioritize) + } + } + + protected fun preloadPages(page: ViewerPage) { + // Get the canonical page from cache to ensure we're working with current data + val key = pageKey(page) + val cachedPage = synchronized(lock) { findInCache(key) } ?: return + + // Priority order: current (highest), next1, next2, prev1, prev2 (lowest) + // Add in reverse for LIFO, current page gets prioritized + + // Add prev pages (lowest priority) + val prevPages = mutableListOf() + var p: ViewerPage? = cachedPage + for (i in 0 until preloadCount) { + p = p?.prev ?: break + prevPages.add(p) + } + prevPages.asReversed().forEach { preloadPage(it) } + + // Add next pages (medium priority) + val nextPages = mutableListOf() + p = cachedPage + for (i in 0 until preloadCount) { + p = p?.next ?: break + nextPages.add(p) + } + nextPages.asReversed().forEach { preloadPage(it) } + + // Add current spread last with priority flag (highest priority in LIFO) + // Also preload the paired page + cachedPage.next?.let { preloadPage(it, prioritize = true) } + preloadPage(cachedPage, prioritize = true) + } + + /** + * 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) { + val pages = chapters.currChapter.pages ?: return + + this.viewerChapters = chapters + + val requestedIndex = min(chapters.currChapter.requestedPage, pages.lastIndex) + val requestedPage = pages[requestedIndex] + + // Get the page and align to spread anchor if needed + val page = currentPage ?: getPage(requestedPage) + currentPage = getSpreadAnchor(page) + (currentPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } + preloadPages(currentPage!!) + + pager.state.apply { + onPageChange = onPageChange@{ delta -> + activity.hideMenu() + + // The viewer already showed the page at fetchPage(delta). + // We need to update currentPage to match that. + val direction = if (isReversed) -delta else delta + val current = currentPage ?: return@onPageChange + + // Navigate the same way fetchPage does + var page = current + val step = if (direction > 0) 1 else -1 + repeat(abs(direction)) { + page = nextPage(page, step) ?: return@onPageChange + } + + currentPage = page + (page as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } + preloadPages(page) + + (page as? TransitionPage)?.let { transitionPage -> + if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + activity.showMenu() + } + } + } + + invalidate() + } + } + + /** + * Tells this viewer to move to the given [page]. + * In dual page mode, aligns to the start of the spread containing the page. + */ + override fun moveToPage(page: ReaderPage) { + // Get the page and align to spread anchor based on image position + moveToPage(getSpreadAnchor(getPage(page))) + } + + private fun moveToPage(newPage: ViewerPage) { + val previousPage = currentPage + + currentPage = newPage + (newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } + preloadPages(newPage) + + (newPage as? TransitionPage)?.let { transitionPage -> + if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + activity.showMenu() + } + } + + if (previousPage == null) return + + val direction = when (previousPage) { + is ViewerReaderPage if newPage is ViewerReaderPage -> if (previousPage.page.chapter == + newPage.page.chapter + ) { + (newPage.page.index - previousPage.page.index).coerceIn(-1, 1) + } else if (previousPage.page.chapter == newPage.prevChapter) { + 1 + } else { + -1 + } + + is TransitionPage if newPage is ViewerReaderPage -> if (previousPage.nextChapter == newPage.page.chapter) { + 1 + } else { + -1 + } + + is ViewerReaderPage if newPage is TransitionPage -> if (previousPage.page.chapter == newPage.prevChapter) { + 1 + } else { + -1 + } + + else -> 0 + } + + if (direction != 0) { + pager.state.transitionFromPage = buildSpreadPage(previousPage) + pager.state.animatePageTurn(if (isReversed) direction else -direction) + } else { + pager.state.invalidate() + } + } + + /** + * Moves to the next page. + */ + fun moveToNext() { + moveRight() + } + + /** + * Moves to the previous page. + */ + fun moveToPrevious() { + moveLeft() + } + + /** + * Moves to the page at the right. + */ + protected open fun moveRight() { + pager.state.getPage(0)?.let { page -> + val isWidePage = page.homeScaleOverride != null + if (config.navigateToPan && (!page.atHome || isWidePage)) { + val maxX = page.maxX(page.scale) + val c = if (isReversed) -1 else 1 + val x = (page.x - c / page.scale).coerceIn(-maxX, maxX) + if (x != page.x) { + if (page.animationJob?.isActive == true && page.animationTargetX == x) { + page.animationJob?.cancel() + } else { + page.animateTo(targetX = x, targetY = page.y) + return + } + } + } + + navigateSpread(1) + } + } + + /** + * Moves to the page at the left. + */ + protected open fun moveLeft() { + pager.state.getPage(0)?.let { page -> + val isWidePage = page.homeScaleOverride != null + if (config.navigateToPan && (!page.atHome || isWidePage)) { + val maxX = page.maxX(page.scale) + val c = if (isReversed) -1 else 1 + val x = (page.x + c / page.scale).coerceIn(-maxX, maxX) + if (x != page.x) { + if (page.animationJob?.isActive == true && page.animationTargetX == x) { + page.animationJob?.cancel() + } else { + page.animateTo(targetX = x, targetY = page.y) + return + } + } + } + + navigateSpread(-1) + } + } + + /** + * Get the target page when navigating by spreads from the given page. + * @param from Starting page + * @param direction Positive = forward in page numbers, negative = backward + * @return Target page or null if navigation not possible + */ + private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? { + var page = getSpreadAnchor(from) + + page = if (direction > 0) { + // Going forward (next spread) + if (page is ViewerReaderPage && canFormSpread(page)) { + page.next?.next ?: return null + } else { + page.next ?: return null + } + } else { + // Going backward (prev spread) + page.prev ?: return null + } + + return getSpreadAnchor(page) + } + + /** + * Navigate by spreads from current page. + * @param direction Positive = forward in page numbers, negative = backward + */ + private fun navigateSpread(direction: Int) { + val target = currentPage?.let { nextPage(it, direction) } ?: return + moveToPage(target) + } + + /** + * Moves to the page at the top (or previous). + */ + protected fun moveUp() { + moveToPrevious() + } + + /** + * Moves to the page at the bottom (or next). + */ + protected fun moveDown() { + moveToNext() + } + + /** + * 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt new file mode 100644 index 000000000..3bee77552 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.webgpu + +import ca.mpreg.webgpuviewer.ImageViewContinuous +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.reader.model.ReaderPage + +class WebGpuViewerContinuous(activity: ReaderActivity) : + WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) { + + override val isContinuous: Boolean = true + + private fun scrollByHalfPage(direction: Int) { + val state = (pager as ImageViewContinuous).state + val totalDistance = direction * state.height / 2f + state.animateScroll(totalDistance) + } + + override fun moveRight() = scrollByHalfPage(1) + + override fun moveLeft() = scrollByHalfPage(-1) + + override fun moveToPage(page: ReaderPage) { + super.moveToPage(page) + (pager as ImageViewContinuous).state.scrollY = 0f + } +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index e5b8eb7e0..e38d17a5e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { api(libs.okio) implementation(libs.image.decoder) + implementation(libs.image.decoder2) implementation(libs.unifile) implementation(libs.archive) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f318ba1c9..1d6ec629a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,9 +40,11 @@ firebase-crashlytics = "3.0.7" flexibleAdapter = "c8013533" google-services = "4.5.0" image-decoder = "e03b81e18a" +imagedecoder2 = "6" injekt = "91edab2317" jsoup = "1.23.1" junit = "6.1.3" +kim = "0.34.0" kotest-assertions = "6.2.4" kotlin-gradle = "2.4.10" kotlinx-coroutines = "1.11.0" @@ -72,6 +74,7 @@ swipe = "1.3.0" tapmoc = "0.4.2" unifile = "08f224c8f9" voyager = "2.2.21-1.10.3" +webgpuviewer = "25" xmlutil = "1.0.2" [libraries] @@ -133,10 +136,12 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } flexibleAdapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexibleAdapter" } image-decoder = { module = "com.github.mihonapp:image-decoder", version.ref = "image-decoder" } +image-decoder2 = { module = "ca.mpreg:imagedecoder", version.ref = "imagedecoder2" } injekt = { module = "com.github.mihonapp:injekt", version.ref = "injekt" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +kim = { module = "de.stefan-oltmann:kim", version.ref = "kim" } kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest-assertions" } kotlin-compose-compiler-gradle = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin-gradle" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle" } @@ -186,6 +191,7 @@ unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } +webgpuviewer = { module = "ca.mpreg:webgpuviewer", version.ref = "webgpuviewer" } xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "xmlutil" } xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 614735c59..6e9bb354b 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -407,6 +407,7 @@ Place vertical navigator on the left side Show reading mode Briefly show current mode when reader is opened + Use high quality renderer Custom hardware bitmap threshold Default (%d) If reader loads a blank image incrementally reduce the threshold.\nSelected: %s @@ -501,6 +502,28 @@ Low Lowest Disable zoom out + WebGPU + Transition animation + Default + Page flip (left) + Page flip (right) + Stack (left) + Stack (right) + Stack (up) + Stack (down) + Sphere + Cube (Inside) + Cube (Outside) + Fade + Fade to white + Display cutout mode + Ignore + Avoid + Shift + Dual page view + Never + Always + When wide Delete chapters