Add high quality WebGPU renderer (#3388)

This commit is contained in:
w
2026-08-14 10:01:00 -07:00
committed by GitHub
parent 581a89b7b0
commit 5c3ae37d52
16 changed files with 1731 additions and 4 deletions
@@ -33,6 +33,8 @@ 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(
@@ -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<BasePreferences>().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() }
@@ -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
@@ -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<ReaderPreferences>().defaultReadingMode.get()
val resolved = ReadingMode.fromPreference(
when {
readingMode == ReadingMode.DEFAULT -> default
else -> manga?.readingMode?.toInt() ?: default
},
)
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
SettingsChipRow(MR.strings.pref_dual_page_view) {
ReaderPreferences.DualPageView.entries.map {
FilterChip(
selected = it == dualPageView,
onClick = { viewModel.preferences.dualPageView.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
}
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)) },
)
}
}
}
@@ -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,
)
}
}
@@ -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)
@@ -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
@@ -132,6 +132,11 @@ class ReaderPreferences(
false,
)
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
"pref_dual_page_view",
DualPageView.NEVER,
)
// endregion
// region Color filter
@@ -189,6 +194,15 @@ class ReaderPreferences(
// endregion
// region WebGpu
val transitionAnimation: Preference<TransitionAnimation> =
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.DEFAULT)
val cutoutMode: Preference<CutoutMode> = 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
@@ -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<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)
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)
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
}