From 487c313d31ae9cf746a5d324550fdbae5f6c7b99 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 20 Oct 2026 00:16:11 -0700 Subject: [PATCH] Bump webgpuviewer (#4146) Co-authored-by: AntsyLich <59261191+antsylich@users.noreply.github.com> --- .../reader/settings/ReadingModePage.kt | 195 +--------- .../tachiyomi/data/coil/ImageDecoder.kt | 122 +++--- .../ui/reader/setting/ReaderPreferences.kt | 21 + .../reader/viewer/webgpu/ReaderChromePages.kt | 255 ++++++++++++ .../ui/reader/viewer/webgpu/WebGpuConfig.kt | 24 ++ .../ui/reader/viewer/webgpu/WebGpuShaders.kt | 113 ++++++ .../ui/reader/viewer/webgpu/WebGpuViewer.kt | 277 ++++--------- .../viewer/webgpu/WebGpuViewerSettings.kt | 366 ++++++++++++++++++ gradle/libs.versions.toml | 6 +- .../moko-resources/base/strings.xml | 5 + 10 files changed, 925 insertions(+), 459 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuShaders.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerSettings.kt 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 62c34176b..e1bcaf2af 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 @@ -8,7 +8,6 @@ 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 @@ -16,8 +15,8 @@ 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.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 @@ -29,10 +28,15 @@ import java.text.NumberFormat @Composable internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { - HeadingItem(MR.strings.pref_category_for_this_series) - val manga by viewModel.mangaFlow.collectAsState() val viewer by viewModel.viewerFlow.collectAsState() + if (viewer is WebGpuViewer) { + 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 { @@ -44,62 +48,6 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - if (viewer is WebGpuViewer) { - val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() - val resolved = ReadingMode.fromPreference( - when { - readingMode == ReadingMode.DEFAULT -> default - else -> manga?.readingMode?.toInt() ?: default - }, - ) - if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { - val dualPageView by viewModel.preferences.dualPageView.collectAsState() - SettingsChipRow(MR.strings.pref_dual_page_view) { - ReaderPreferences.DualPageView.entries.map { - FilterChip( - selected = it == dualPageView, - onClick = { viewModel.preferences.dualPageView.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - } - - if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) { - val numberFormat = remember { NumberFormat.getPercentInstance() } - val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState() - SliderItem( - value = continuousMinWidth, - valueRange = ReaderPreferences.let { 1..100 }, - label = stringResource(MR.strings.pref_continuous_minwidth), - valueString = numberFormat.format(continuousMinWidth / 100f), - onChange = { - viewModel.preferences.continuousMinWidth.set(it) - }, - pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) - - if (resolved == ReadingMode.CONTINUOUS_VERTICAL) { - val continuousGap by viewModel.preferences.continuousGap.collectAsState() - SliderItem( - value = continuousGap, - valueRange = ReaderPreferences.let { 1..100 }, - label = stringResource(MR.strings.pref_continuous_gap), - valueString = numberFormat.format(continuousGap / 100f), - onChange = { - viewModel.preferences.continuousGap.set(it) - }, - pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) - } - - CheckboxItem( - label = stringResource(MR.strings.pref_webtoon_disable_zoom_out), - pref = viewModel.preferences.webtoonDisableZoomOut, - ) - } - } - val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) } SettingsChipRow(MR.strings.rotation_type) { ReaderOrientation.entries.map { @@ -111,10 +59,10 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - when (viewer) { - is WebtoonViewer -> WebtoonViewerSettings(viewModel) - is WebGpuViewer -> WebGpuViewerSettings(viewModel) - else -> PagerViewerSettings(viewModel) + if (viewer is WebtoonViewer) { + WebtoonViewerSettings(viewModel) + } else { + PagerViewerSettings(viewModel) } } @@ -264,7 +212,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel } @Composable -private fun ColumnScope.TapZonesItems( +internal fun TapZonesItems( selected: Int, onSelect: (Int) -> Unit, invertMode: ReaderPreferences.TappingInvertMode, @@ -292,120 +240,3 @@ private fun ColumnScope.TapZonesItems( } } } - -@Composable -private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { - HeadingItem(MR.strings.webgpu_viewer) - - val manga by viewModel.mangaFlow.collectAsState() - val viewer by viewModel.viewerFlow.collectAsState() - - val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } - val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() - val resolved = ReadingMode.fromPreference( - when { - readingMode == ReadingMode.DEFAULT -> default - else -> manga?.readingMode?.toInt() ?: default - }, - ) - val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true - - val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() - val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState() - TapZonesItems( - selected = navigationModePager, - onSelect = viewModel.preferences.navigationModePager::set, - invertMode = pagerNavInverted, - onSelectInvertMode = viewModel.preferences.pagerNavInverted::set, - ) - - if (isDual) { - val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState() - SettingsChipRow(MR.strings.pref_transition_animation_dual) { - ( - ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT - - ReaderPreferences.TransitionAnimation.FLIP_RIGHT - ).forEach { - FilterChip( - selected = it == transitionAnimation, - onClick = { viewModel.preferences.transitionAnimationDual.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState() - SettingsChipRow(MR.strings.pref_cutout_mode_dual) { - ReaderPreferences.CutoutMode.entries.forEach { - FilterChip( - selected = it == cutoutMode, - onClick = { viewModel.preferences.cutoutModeDual.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - return - } - - if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) { - val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() - SettingsChipRow(MR.strings.pref_image_scale_type) { - ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { - FilterChip( - selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it, - onClick = { - viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1) - }, - label = { Text(stringResource(it)) }, - ) - } - } - - val zoomStart by viewModel.preferences.zoomStart.collectAsState() - SettingsChipRow(MR.strings.pref_zoom_start) { - ReaderPreferences.ZoomStart.mapIndexed { index, it -> - FilterChip( - selected = zoomStart == index + 1, - onClick = { viewModel.preferences.zoomStart.set(index + 1) }, - label = { Text(stringResource(it)) }, - ) - } - } - - CheckboxItem( - label = stringResource(MR.strings.pref_crop_borders), - pref = viewModel.preferences.cropBorders, - ) - - CheckboxItem( - label = stringResource(MR.strings.pref_landscape_zoom), - pref = viewModel.preferences.landscapeZoom, - ) - - CheckboxItem( - label = stringResource(MR.strings.pref_navigate_pan), - pref = viewModel.preferences.navigateToPan, - ) - - val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState() - SettingsChipRow(MR.strings.pref_transition_animation) { - ReaderPreferences.TransitionAnimation.entries.forEach { - FilterChip( - selected = it == transitionAnimation, - onClick = { viewModel.preferences.transitionAnimation.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - - val cutoutMode by viewModel.preferences.cutoutMode.collectAsState() - SettingsChipRow(MR.strings.pref_cutout_mode) { - ReaderPreferences.CutoutMode.entries.forEach { - FilterChip( - selected = it == cutoutMode, - onClick = { viewModel.preferences.cutoutMode.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt index 2bdf47fc0..72787d7a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.coil import android.graphics.Bitmap import android.graphics.Matrix import androidx.core.graphics.createBitmap -import androidx.core.graphics.scale import ca.mpreg.imagedecoder.ImageDecoder import coil3.Canvas import coil3.Image @@ -64,96 +63,73 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti override suspend fun decode(): DecodeResult { if (options.webtoonStrips) return decodeStrips() - val res = resources.source().use { + return resources.source().use { ImageDecoder.open(it.inputStream()).use { dec -> - DecodeResultImage( - dec.decodeNext(), - dec.isHdr, - dec.hdrHeadroom, - if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null, + // Raw frame for custom rendering; sampling is the caller's. + if (options.newDecoder) { + return@use DecodeResult( + image = DecodeResultImage( + dec.decodeNext(), + dec.isHdr, + dec.hdrHeadroom, + if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null, + ), + isSampled = false, + ) + } + + // Shrunk while decoding. Sizes round up, so may be a pixel over BitmapFactory's. + val sampleSize = DecodeUtils.calculateInSampleSize( + srcWidth = dec.width, + srcHeight = dec.height, + dstWidth = options.size.widthPx(options.scale) { dec.width }, + dstHeight = options.size.heightPx(options.scale) { dec.height }, + scale = options.scale, + ) + DecodeResult( + image = dec.decodeToBitmap(null, sampleSize).asImage(), + isSampled = sampleSize > 1, ) } } - - val srcWidth = res.width - val srcHeight = res.height - - // newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering). - // Hand it back as-is; sampling is the caller's responsibility. - if (options.newDecoder) { - return DecodeResult( - image = res, - isSampled = false, - ) - } - - // Normal path: produce a Bitmap scaled to the requested output size. - val dstWidth = options.size.widthPx(options.scale) { srcWidth } - val dstHeight = options.size.heightPx(options.scale) { srcHeight } - val sampleSize = DecodeUtils.calculateInSampleSize( - srcWidth = srcWidth, - srcHeight = srcHeight, - dstWidth = dstWidth, - dstHeight = dstHeight, - scale = options.scale, - ) - - // Copy RGBA pixels from the native buffer into a full-resolution bitmap. - // We must do this while `res` (and its native memory) is still alive. - // HDR frames are half-float RGBA. - val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 - val fullBitmap = createBitmap(srcWidth, srcHeight, config) - res.image.rewind() - fullBitmap.copyPixelsFromBuffer(res.image) - res.frame.close() - - // Downsample if needed. sampleSize is a power-of-two factor; the target - // dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour. - val bitmap = if (sampleSize > 1) { - val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1) - val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1) - val scaled = fullBitmap.scale(scaledWidth, scaledHeight) - fullBitmap.recycle() - scaled - } else { - fullBitmap - } - - return DecodeResult( - image = bitmap.asImage(), - isSampled = sampleSize > 1, - ) } /** * Copies bands of rows straight from the native frame into strips, so the page never exists as - * one Java bitmap on top of the frame. + * one Java bitmap on top of the frame. The frame is decoded once, at the largest whole fraction + * still at least the strip width: per-strip regions would re-decode the rows above each. * * Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming * in still shows the page's detail, unless that would make the page take more than * [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed. */ private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit { - val (frame, isHdr) = resources.source().use { - ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr } + val (frame, isHdr, widths) = resources.source().use { + ImageDecoder.open(it.inputStream()).use { dec -> + val bytesPerPixel = if (dec.isHdr) 8 else 4 + val viewWidth = options.size.widthPx(options.scale) { dec.width } + val sampleSize = DecodeUtils.calculateInSampleSize( + srcWidth = dec.width, + srcHeight = dec.height, + dstWidth = viewWidth, + dstHeight = options.size.heightPx(options.scale) { dec.height }, + scale = options.scale, + ) + val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * dec.width / dec.height).toInt() + val stripWidth = maxOf(viewWidth, min(dec.width / sampleSize, budgetWidth)) + // Older GPUs can't draw a texture wider than this + .coerceAtMost(MAX_STRIP_WIDTH) + .coerceIn(1, dec.width) + // ceil(width / decodeSample) >= stripWidth: strips only scale down + val decodeSample = (dec.width / stripWidth).coerceAtLeast(1) + Triple(dec.decode(null, decodeSample), dec.isHdr, stripWidth to dec.width) + } } + val (stripWidth, pageWidth) = widths frame.use { val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 val bytesPerPixel = if (isHdr) 8 else 4 val rowBytes = frame.width * bytesPerPixel - val viewWidth = options.size.widthPx(options.scale) { frame.width } - val sampleSize = DecodeUtils.calculateInSampleSize( - srcWidth = frame.width, - srcHeight = frame.height, - dstWidth = viewWidth, - dstHeight = options.size.heightPx(options.scale) { frame.height }, - scale = options.scale, - ) - val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt() - val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth)) - // Older GPUs can't draw a texture wider than this - .coerceAtMost(MAX_STRIP_WIDTH) - .coerceIn(1, frame.width) val scale = stripWidth.toFloat() / frame.width // Strips of about the same size in bytes, so each takes about as long to upload @@ -179,7 +155,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti strip.copy(config, false).also { if (strip !== band) strip.recycle() } } band.recycle() - DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width) + DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth) } } 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 2109ce68f..13bdc4ef6 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 @@ -6,6 +6,8 @@ import dev.icerock.moko.resources.StringResource import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShader +import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShaders import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.getEnum @@ -220,6 +222,25 @@ class ReaderPreferences( val continuousGap: Preference = preferenceStore.getInt("webgpu_continuous_gap", 10) + val disableDoubleTapGestures: Preference = + preferenceStore.getBoolean("webgpu_disable_double_tap_gestures", false) + + /** In run order. */ + val imageFilters: Preference> = preferenceStore.getObjectFromString( + "webgpu_image_filters", + emptyList(), + WebGpuShaders::serialize, + WebGpuShaders::deserialize, + ) + + /** In run order. */ + val tileFilters: Preference> = preferenceStore.getObjectFromString( + "webgpu_tile_filters", + emptyList(), + WebGpuShaders::serialize, + WebGpuShaders::deserialize, + ) + // endregion enum class FlashColor { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt new file mode 100644 index 000000000..6df7366bf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt @@ -0,0 +1,255 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.webgpu + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Button +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.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.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +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.theme.TachiyomiTheme +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.webview.WebViewActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.CombinedCircularProgressIndicator +import tachiyomi.presentation.core.i18n.stringResource +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. + * + * Under the reader's own night mode rather than the app's: the page sits 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 pager and webtoon adapters + * used to get by inflating their views from a reader-themed context. + */ + @Composable + final override fun BoxScope.Content() { + val context = viewer.readerThemedContext + CompositionLocalProvider( + LocalContext provides context, + LocalConfiguration provides context.resources.configuration, + ) { + TachiyomiTheme { 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)) + } + }, + ) + } +} + +/** `reader_error.xml`, in Compose. */ +@Composable +private fun ReaderError( + message: String?, + onRetry: () -> Unit, + onOpenInWebView: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = message ?: stringResource(MR.strings.decode_image_error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Button(onClick = onRetry) { + Text(stringResource(MR.strings.action_retry)) + } + if (onOpenInWebView != null) { + Button(onClick = onOpenInWebView) { + Text(stringResource(MR.strings.action_open_in_web_view)) + } + } + } +} + +/** 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) { + 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, + ) + } +} 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 index 886aac5f5..d892ba527 100644 --- 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 @@ -73,6 +73,16 @@ class WebGpuConfig( var continuousGap = 10 private set + var imageFilters = emptyList() + private set + + var imageFiltersChangedListener: (() -> Unit)? = null + + var tileFilters = emptyList() + private set + + var tileFiltersChangedListener: (() -> Unit)? = null + init { readerPreferences.readerTheme.register( { @@ -161,10 +171,24 @@ class WebGpuConfig( { imagePropertyChangedListener?.invoke() }, ) + readerPreferences.disableDoubleTapGestures.register({ + viewer.pager.state.doubleTapGesturesEnabled = !it + }) + readerPreferences.continuousGap.register( { continuousGap = it }, { imagePropertyChangedListener?.invoke() }, ) + + readerPreferences.imageFilters.register( + { imageFilters = it }, + { imageFiltersChangedListener?.invoke() }, + ) + + readerPreferences.tileFilters.register( + { tileFilters = it }, + { tileFiltersChangedListener?.invoke() }, + ) } private fun zoomTypeFromPreference(value: Int) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuShaders.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuShaders.kt new file mode 100644 index 000000000..61d417305 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuShaders.kt @@ -0,0 +1,113 @@ +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): String = + shaders.joinToString("\n") { "${it.file}\t${it.name}" } + + fun deserialize(text: String): List = 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>) = + 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>, 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>>) = + withContext(Dispatchers.IO) { + synchronized(lock) { + val dir = dir(context) + val present = dir.list()?.toSet().orEmpty() + val named = HashSet() + 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) +} 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 index 55262b47b..4d13e7b50 100644 --- 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 @@ -1,6 +1,6 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webgpu -import android.graphics.Color +import android.content.Context import android.graphics.PointF import android.graphics.Rect import android.os.SystemClock @@ -8,17 +8,9 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.spring -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastCoerceIn -import androidx.webgpu.GPUTexture import ca.mpreg.imagedecoder.ImageDecoder import ca.mpreg.webgpuviewer.ImageView import ca.mpreg.webgpuviewer.closeTo -import ca.mpreg.webgpuviewer.draw.TextAlign import ca.mpreg.webgpuviewer.renderer.GainmapInput import ca.mpreg.webgpuviewer.renderer.Image import ca.mpreg.webgpuviewer.transition.TransitionBasic @@ -38,7 +30,6 @@ import ca.mpreg.webgpuviewer.transition.TransitionStackUp import ca.mpreg.webgpuviewer.viewer.AnimationFrame import ca.mpreg.webgpuviewer.viewer.ImagePage import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState -import com.google.android.material.color.MaterialColors import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader @@ -98,18 +89,18 @@ open class WebGpuViewer( @Volatile private var cachedBackgroundColor: Int? = null - @Volatile - private var cachedOnBackgroundColor: Int? = null - - protected fun readerBackgroundColor(): Int = + internal fun readerBackgroundColor(): Int = cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme) .also { cachedBackgroundColor = it } - private fun readerOnBackgroundColor(): Int = cachedOnBackgroundColor ?: MaterialColors.getColor( - activity.createReaderThemeContext(), - com.google.android.material.R.attr.colorOnBackground, - Color.WHITE, - ).also { cachedOnBackgroundColor = it } + @Volatile + private var cachedThemedContext: Context? = null + + /** The reader's night mode rather than the app's - see [ReaderComposePage]. */ + internal val readerThemedContext: Context + get() = cachedThemedContext ?: activity.createReaderThemeContext().also { cachedThemedContext = it } + + val downloadManager by lazy { activity.appGraph.downloadManager } protected val scope = MainScope() @@ -248,8 +239,7 @@ open class WebGpuViewer( synchronized(lock) { if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { val oldImagePage = page.imagePage - val errorMessage = e.message ?: "Failed to decode image" - page.imagePage = ErrorPage(errorMessage, page.spreadPosition) + page.imagePage = ErrorPage(this@WebGpuViewer, page, e) page.preview = null page.state = PageState.IDLE cleanupImage(oldImagePage) @@ -498,177 +488,6 @@ open class WebGpuViewer( } } - inner class ErrorPage internal constructor( - message: String, - private val spreadPosition: SpreadPosition = SpreadPosition.SINGLE, - ) : ImagePage.Render(0, 0) { - override val width: Int - get() = viewportPageWidth(spreadPosition != SpreadPosition.SINGLE) - override val height: Int - get() = pager.state.height - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - var message: String = message - set(value) { - field = value - invalidate() - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - val padding = with(pager.state.density) { 24.dp.toPx() } - val size = scale * with(pager.state.density) { 16.dp.toPx() } - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - text( - dst, - activity.baseContext, - FontFamily.Default, - message, - cx, - cy, - size, - color = readerOnBackgroundColor(), - align = TextAlign.Center, - maxWidth = dst.width - 2f * padding, - ) - } - } - - inner class ProgressPage(foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render(0, 0) { - override val width: Int - get() = viewportPageWidth(isDualPageMode()) - override val height: Int - get() = pager.state.height - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - @Volatile - private var progressValue: Float = 0f - private var progressJob: Job? = null - var progress: Float - get() = progressValue - set(value) { - val target = value.fastCoerceIn(0f, 1f) - synchronized(this) { - progressJob?.cancel() - progressJob = null - - if (destroyed || progressValue == target) return - - val scope = scope ?: run { - progressValue = target - invalidate() - return - } - - val start = progressValue - progressJob = scope.launch { - animate( - start, - target, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ) { current, _ -> - progressValue = current - invalidate() - } - } - } - } - - override fun cleanup() { - super.cleanup() - synchronized(this) { - progressJob?.cancel() - progressJob = null - } - } - - var foregroundColor: Int = foregroundColor - set(value) { - field = value - invalidate() - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - // Its own footprint, so the page carries its background wherever a transition puts it. - fillPage(dst, x, y, scale, backgroundColor) - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - val full = min(width, height) * 0.25f * scale - - circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt()) - - val diameter = full * progress.fastCoerceIn(0f, 1f) - if (diameter > 0) { - circle(cx, cy, diameter / 2f, foregroundColor) - } - } - } - - inner class TransitionPage(val prevChapter: ReaderChapter?, val nextChapter: ReaderChapter?) : - ImagePage.Render(0, 0) { - /** Square, and never a spread side - [buildSpreadPage] hands it back whole. */ - override val width: Int - get() = min(pager.state.width, pager.state.height) - override val height: Int - get() = width - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - // Its own footprint, so the page carries its background wherever a transition puts it. - fillPage(dst, x, y, scale, backgroundColor) - - val lines: MutableList = mutableListOf() - prevChapter?.chapter?.let { chapter -> lines.add("Previous: " + chapter.name) } - nextChapter?.chapter?.let { chapter -> lines.add("Next: " + chapter.name) } - - val text = lines.joinToString("\n") - - val padding = with(pager.state.density) { 24.dp.toPx() } - val size = scale * with(pager.state.density) { 16.dp.toPx() } - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - text( - dst, - activity.baseContext, - FontFamily.Default, - text, - cx, - cy, - size, - readerOnBackgroundColor(), - align = TextAlign.Center, - maxWidth = dst.width - 2f * padding, - ) - } - } - abstract class ViewerPage { abstract val prevChapter: ReaderChapter? abstract val nextChapter: ReaderChapter? @@ -688,7 +507,7 @@ open class WebGpuViewer( override val prevChapter: ReaderChapter?, override val nextChapter: ReaderChapter?, ) : ViewerPage() { - override var imagePage: ImagePage = TransitionPage(prevChapter, nextChapter) + override var imagePage: ImagePage = TransitionPage(this@WebGpuViewer, prevChapter, nextChapter) override val prev: ViewerPage? get() = prevChapter?.pages?.lastOrNull()?.let { getPage(it, currentPage) } @@ -707,7 +526,7 @@ open class WebGpuViewer( /** The decoded image's shape, or null while this page is still a placeholder. */ internal val aspectRatio: Float? - get() = (imagePage as? ImagePage.ImageSingle)?.let { + get() = (imagePage as? ImagePage.ImageSingle)?.takeIf { it !is ImagePage.Compose }?.let { val height = it.trimHeight if (it.isDecoded && height > 0) it.trimWidth.toFloat() / height else null } @@ -730,7 +549,7 @@ open class WebGpuViewer( internal val standsAlone: Boolean get() = taggedSpreadPosition == SpreadPosition.SINGLE || (aspectRatio ?: 0f) > wideAspect - override var imagePage: ImagePage = ProgressPage() + override var imagePage: ImagePage = ProgressPage(this@WebGpuViewer) /** So a download streams once; weak, as it holds the whole download. Under [lock]. */ internal var streamedFrom: WeakReference? = null @@ -738,12 +557,14 @@ open class WebGpuViewer( /** A streaming decode owns the page. Under [lock]. */ internal var streaming = false - /** Not counted by [isDecoded]. */ + /** Not counted by [isDecoded], and nor is the reader's chrome, which is always "decoded". */ @Volatile internal var preview: ImagePage? = null override val isDecoded - get() = imagePage.let { it !== preview && (it as? ImagePage.ImageSingle)?.isDecoded == true } + get() = imagePage.let { + it !== preview && it !is ImagePage.Compose && (it as? ImagePage.ImageSingle)?.isDecoded == true + } override val prevChapter: ReaderChapter? get() = when (page.chapter) { @@ -975,7 +796,7 @@ open class WebGpuViewer( config.imagePropertyChangedListener = { // A theme change comes through here. cachedBackgroundColor = null - cachedOnBackgroundColor = null + cachedThemedContext = null val isDual = isDualPageMode() pager.state.apply { @@ -1052,6 +873,34 @@ open class WebGpuViewer( val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) } + + config.imageFiltersChangedListener = { + val shaders = config.imageFilters + // Else an older, slower load could land last. + imageFiltersJob?.cancel() + imageFiltersJob = scope.launch { pager.state.imageFilters = loadFilters(shaders) } + } + + config.tileFiltersChangedListener = { + val shaders = config.tileFilters + tileFiltersJob?.cancel() + tileFiltersJob = scope.launch { pager.state.tileFilters = loadFilters(shaders) } + } + } + + private var imageFiltersJob: Job? = null + private var tileFiltersJob: Job? = null + + /** Fresh instances: one belongs to one viewer and one chain. Skips any that fail to load. */ + private suspend fun loadFilters(shaders: List) = withContext(Dispatchers.IO) { + shaders.mapNotNull { shader -> + try { + WebGpuShaders.load(activity, shader) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "filter ${shader.name}" } + null + } + } } override fun destroy() { @@ -1106,6 +955,22 @@ open class WebGpuViewer( } } + /** [ErrorPage]'s Retry: back to the ring, then loaded and decoded afresh. */ + internal fun retryLoad(page: ViewerReaderPage) { + synchronized(lock) { + if (!pageInCache(page) || page.imagePage !is ErrorPage) return + val old = page.imagePage + page.imagePage = ProgressPage(this) + cleanupImage(old) + if (page.state != PageState.LOADING) page.state = PageState.IDLE + } + page.page.chapter.pageLoader?.retryPage(page.page) + synchronized(lock) { + if (pageInCache(page)) queueForDecode(page, prioritize = true) + } + pager.state.invalidate() + } + /** Downloads [page] if needed, then re-queues it for decode once ready. */ private fun startPageLoad(page: ViewerReaderPage) { val loader = page.page.chapter.pageLoader ?: run { @@ -1169,6 +1034,15 @@ open class WebGpuViewer( downloadProgressJob.cancel() synchronized(lock) { + val status = page.page.status + if (pageInCache(page) && status is Page.State.Error && !page.streaming) { + val old = page.imagePage + if (old is ProgressPage) { + page.imagePage = ErrorPage(this@WebGpuViewer, page, status.error) + cleanupImage(old) + pager.state.invalidate() + } + } if (pageInCache(page) && page.state == PageState.LOADING) { page.state = PageState.IDLE if (page.page.status == Page.State.Ready && !page.isDecoded && !page.streaming) { @@ -1299,11 +1173,12 @@ open class WebGpuViewer( } finally { synchronized(lock) { page.streaming = false - // Failed download: back to the ring for a retry. + // Failed download: the error, with Retry. val preview = page.preview - if (pageInCache(page) && page.imagePage === preview && page.page.status is Page.State.Error) { + val status = page.page.status + if (pageInCache(page) && page.imagePage === preview && status is Page.State.Error) { page.preview = null - page.imagePage = ProgressPage() + page.imagePage = ErrorPage(this@WebGpuViewer, page, status.error) cleanupImage(preview) pager.state.invalidate() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerSettings.kt new file mode 100644 index 000000000..36d03d061 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerSettings.kt @@ -0,0 +1,366 @@ +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.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.app.di.appGraph +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 viewer by viewModel.viewerFlow.collectAsState() + + val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } + SettingsChipRow(MR.strings.pref_category_reading_mode) { + ReadingMode.entries.map { + FilterChip( + selected = it == readingMode, + onClick = { viewModel.onChangeReadingMode(it) }, + label = { Text(stringResource(it.stringRes)) }, + ) + } + } + + val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() + val resolved = ReadingMode.fromPreference( + when { + readingMode == ReadingMode.DEFAULT -> default + else -> manga?.readingMode?.toInt() ?: default + }, + ) + if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { + val dualPageView by viewModel.preferences.dualPageView.collectAsState() + SettingsChipRow(MR.strings.pref_dual_page_view) { + ReaderPreferences.DualPageView.entries.map { + FilterChip( + selected = it == dualPageView, + onClick = { viewModel.preferences.dualPageView.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } + } + } + + if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) { + val numberFormat = remember { NumberFormat.getPercentInstance() } + val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState() + SliderItem( + value = continuousMinWidth, + valueRange = ReaderPreferences.let { 1..100 }, + label = stringResource(MR.strings.pref_continuous_minwidth), + valueString = numberFormat.format(continuousMinWidth / 100f), + onChange = { + viewModel.preferences.continuousMinWidth.set(it) + }, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + if (resolved == ReadingMode.CONTINUOUS_VERTICAL) { + val continuousGap by viewModel.preferences.continuousGap.collectAsState() + SliderItem( + value = continuousGap, + valueRange = ReaderPreferences.let { 1..100 }, + label = stringResource(MR.strings.pref_continuous_gap), + valueString = numberFormat.format(continuousGap / 100f), + onChange = { + viewModel.preferences.continuousGap.set(it) + }, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + + CheckboxItem( + label = stringResource(MR.strings.pref_webtoon_disable_zoom_out), + pref = viewModel.preferences.webtoonDisableZoomOut, + ) + } + + val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) } + SettingsChipRow(MR.strings.rotation_type) { + ReaderOrientation.entries.forEach { + FilterChip( + selected = it == orientation, + onClick = { viewModel.onChangeOrientation(it) }, + label = { Text(stringResource(it.stringRes)) }, + ) + } + } + + HeadingItem(MR.strings.webgpu_viewer) + + val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true + + val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() + val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState() + TapZonesItems( + selected = navigationModePager, + onSelect = viewModel.preferences.navigationModePager::set, + invertMode = pagerNavInverted, + onSelectInvertMode = viewModel.preferences.pagerNavInverted::set, + ) + + if (isDual) { + val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState() + SettingsChipRow(MR.strings.pref_transition_animation_dual) { + ( + ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT - + ReaderPreferences.TransitionAnimation.FLIP_RIGHT + ).forEach { + FilterChip( + selected = it == transitionAnimation, + onClick = { viewModel.preferences.transitionAnimationDual.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } + } + val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState() + SettingsChipRow(MR.strings.pref_cutout_mode_dual) { + ReaderPreferences.CutoutMode.entries.forEach { + FilterChip( + selected = it == cutoutMode, + onClick = { viewModel.preferences.cutoutModeDual.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } + } + return + } + + if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) { + val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() + SettingsChipRow(MR.strings.pref_image_scale_type) { + ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { + FilterChip( + selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it, + onClick = { + viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1) + }, + label = { Text(stringResource(it)) }, + ) + } + } + + val zoomStart by viewModel.preferences.zoomStart.collectAsState() + SettingsChipRow(MR.strings.pref_zoom_start) { + ReaderPreferences.ZoomStart.mapIndexed { index, it -> + FilterChip( + selected = zoomStart == index + 1, + onClick = { viewModel.preferences.zoomStart.set(index + 1) }, + label = { Text(stringResource(it)) }, + ) + } + } + + CheckboxItem( + label = stringResource(MR.strings.pref_crop_borders), + pref = viewModel.preferences.cropBorders, + ) + + CheckboxItem( + label = stringResource(MR.strings.pref_landscape_zoom), + pref = viewModel.preferences.landscapeZoom, + ) + + CheckboxItem( + label = stringResource(MR.strings.pref_navigate_pan), + pref = viewModel.preferences.navigateToPan, + ) + + 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>) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val filters by pref.collectAsState() + var error by remember { mutableStateOf(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), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05105e443..a024f12e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ firebase-bom = "35.0.2" firebase-crashlytics = "3.0.8" flexibleAdapter = "5.1.0" google-services = "4.5.0" -image-decoder = "16" +image-decoder = "17" injekt = "1.16.1" jsoup = "1.23.2" junit = "6.1.3" @@ -71,13 +71,13 @@ spotless = "8.10.4" sqldelight = "2.4.1" sqldelight-androidx-driver = "0.2.2" stringSimilarity = "0.1.0" -subsamplingScaleImageView = "1b283e2bc4" +subsamplingScaleImageView = "72a0537151" swipe = "1.3.0" tapmoc = "0.5.0" unifile = "08f224c8f9" valkyrie = "0.5.2" voyager = "2.2.21-1.10.3" -webgpuviewer = "52" +webgpuviewer = "53" xmlutil = "1.0.2" [libraries] diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index f141009ba..112615b02 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -552,6 +552,11 @@ When wide Min width Gap + Image filters + Tile filters + Disable double tap gestures + Add shader + Couldn\'t add shader Delete chapters