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 0cb032f88..8b7fa7552 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 @@ -64,6 +64,21 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } } + + if (resolved == ReadingMode.WEBTOON) { + 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, + ) + } } val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) } @@ -263,6 +278,10 @@ private fun ColumnScope.TapZonesItems( private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { HeadingItem(MR.strings.webgpu_viewer) + val viewer by viewModel.viewerFlow.collectAsState() + + val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true + val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState() TapZonesItems( @@ -272,6 +291,33 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) 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 + } + val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() SettingsChipRow(MR.strings.pref_image_scale_type) { ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { 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 da6502b83..e176f0ab5 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 @@ -202,10 +202,17 @@ class ReaderPreferences( // region WebGpu val transitionAnimation: Preference = - preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.DEFAULT) + preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC) + + val transitionAnimationDual: Preference = + preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC) val cutoutMode: Preference = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID) + val cutoutModeDual: Preference = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE) + + val continuousMinWidth: Preference = preferenceStore.getInt("webgpu_continuous_minwidth", 100) + // endregion enum class FlashColor { @@ -239,7 +246,8 @@ class ReaderPreferences( } enum class TransitionAnimation(val titleRes: StringResource) { - DEFAULT(MR.strings.transition_animation_default), + BASIC(MR.strings.transition_animation_basic), + FLIP(MR.strings.transition_animation_flip), FLIP_LEFT(MR.strings.transition_animation_flip_left), FLIP_RIGHT( MR.strings.transition_animation_flip_right, 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 79db9ed82..3ea2c1112 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 @@ -46,15 +46,24 @@ class WebGpuConfig( var landscapeZoom = false private set - var transitionAnimation = ReaderPreferences.TransitionAnimation.DEFAULT + var transitionAnimation = ReaderPreferences.TransitionAnimation.BASIC + private set + + var transitionAnimationDual = ReaderPreferences.TransitionAnimation.BASIC private set var cutoutMode = ReaderPreferences.CutoutMode.AVOID private set + var cutoutModeDual = ReaderPreferences.CutoutMode.AVOID + private set + var dualPageView = ReaderPreferences.DualPageView.NEVER private set + var continuousMinWidth = 1 + private set + init { readerPreferences.readerTheme .register( @@ -120,17 +129,35 @@ class WebGpuConfig( { imagePropertyChangedListener?.invoke() }, ) + readerPreferences.transitionAnimationDual + .register( + { transitionAnimationDual = it }, + { imagePropertyChangedListener?.invoke() }, + ) + readerPreferences.cutoutMode .register( { cutoutMode = it }, { imagePropertyChangedListener?.invoke() }, ) + readerPreferences.cutoutModeDual + .register( + { cutoutModeDual = it }, + { imagePropertyChangedListener?.invoke() }, + ) + readerPreferences.dualPageView .register( { dualPageView = it }, { imagePropertyChangedListener?.invoke() }, ) + + readerPreferences.continuousMinWidth + .register( + { continuousMinWidth = it }, + { imagePropertyChangedListener?.invoke() }, + ) } private fun zoomTypeFromPreference(value: Int) { 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 4f9a76bec..b01e4516d 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 @@ -20,6 +20,7 @@ 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.TransitionFlip import ca.mpreg.webgpuviewer.transition.TransitionFlipLeft import ca.mpreg.webgpuviewer.transition.TransitionFlipRight import ca.mpreg.webgpuviewer.transition.TransitionNone @@ -29,6 +30,7 @@ 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 ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState import com.google.android.material.color.MaterialColors import de.stefan_oltmann.kim.Kim import de.stefan_oltmann.kim.android.readMetadata @@ -55,6 +57,7 @@ import kotlinx.coroutines.launch import logcat.LogPriority import mihon.app.di.appGraph import tachiyomi.core.common.util.system.logcat +import java.util.TreeSet import java.util.concurrent.Executors import kotlin.math.abs import kotlin.math.min @@ -96,12 +99,24 @@ open class WebGpuViewer( // Decode queue - pages waiting to be decoded, processed LIFO (last = highest priority) private val decodeQueue = ArrayDeque() + /** + * Indices of the pages that take a spread to themselves, by chapter - see [spreadStartIndex]. + * Outlives [pageCache]: every page after one of these depends on it, long since evicted. + */ + private val loneIndices = HashMap>() + /** * Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping * for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself. */ internal enum class SpreadPosition { LEFT, RIGHT, SINGLE } + /** Above this, an untagged page is a spread already, not half of one. */ + private val wideAspect = 1.2f + + /** How far two untagged pages' aspect ratios may differ and still pair. */ + private val pairAspectTolerance = 0.1f + // 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() @@ -232,7 +247,11 @@ open class WebGpuViewer( open val preloadAhead = 3 open val preloadBehind = 2 - open val cacheSize get() = 1 + preloadAhead + preloadBehind + /** + * Everything [preloadPages] reaches, plus slack. Sized exactly, a chapter transition page - or + * in dual mode a spread partner - evicts a page the next fetch asks for, and it decodes again. + */ + open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1 /** * Page processing state @@ -254,9 +273,8 @@ open class WebGpuViewer( */ private fun evictFarthestPage(reference: ViewerPage? = null): Boolean { val current = reference ?: currentPage ?: return false - val candidates = pageCache.values - .filter { it !== current && it !== currentPage && !isPinned(it) } - .toMutableSet() + val candidates = + pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet() if (candidates.isEmpty()) return false fun findNext(page: ViewerPage): ViewerPage? = when (page) { @@ -390,11 +408,13 @@ open class WebGpuViewer( inner class ErrorPage internal constructor( message: String, - spreadPosition: SpreadPosition = SpreadPosition.SINGLE, - ) : ImagePage.Render( - if (spreadPosition == SpreadPosition.SINGLE) pager.state.width else pager.state.width / 2, - pager.state.height, - ) { + 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 @@ -431,10 +451,12 @@ open class WebGpuViewer( } } - inner class ProgressPage(foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render( - if (!isDualPageMode()) pager.state.width else pager.state.width / 2, - pager.state.height, - ) { + 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 @@ -456,6 +478,9 @@ open class WebGpuViewer( 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) @@ -472,10 +497,14 @@ open class WebGpuViewer( } } - inner class TransitionPage(val prevChapter: ReaderChapter?, val nextChapter: ReaderChapter?) : ImagePage.Render( - min(pager.state.width, pager.state.height), - min(pager.state.width, pager.state.height), - ) { + 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 @@ -485,6 +514,9 @@ open class WebGpuViewer( 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) } @@ -544,8 +576,35 @@ open class WebGpuViewer( /** Cached spread ImagePage when this page is the anchor of a dual-page spread */ var spreadPage: ImagePage.ImageSpread? = null - /** Which side of a dual-page spread this page belongs on - set once decoding tags it. */ - internal var spreadPosition: SpreadPosition = SpreadPosition.SINGLE + /** The side the file names, or null for none. Never a value merely derived from the index. */ + @Volatile + internal var taggedSpreadPosition: SpreadPosition? = null + + /** The decoded image's shape, or null while this page is still a placeholder. */ + internal val aspectRatio: Float? + get() = (imagePage as? ImagePage.ImageSingle)?.let { + val height = it.trimHeight + if (it.isDecoded && height > 0) it.trimWidth.toFloat() / height else null + } + + /** + * Which half of a spread this page is on - derived until the file tags it. Without that a + * still-loading page stays SINGLE, never pairs, and its ring draws mid-screen; deriving it + * live also re-decides it on a rotation in or out of dual mode. + * + * Untagged goes by [wideAspect] first, then [derivedSpreadPosition]. + */ + internal val spreadPosition: SpreadPosition + get() { + taggedSpreadPosition?.let { return it } + if (standsAlone) return SpreadPosition.SINGLE + return derivedSpreadPosition(page) + } + + /** True when nothing may share this page's spread - it is one already. */ + internal val standsAlone: Boolean + get() = taggedSpreadPosition == SpreadPosition.SINGLE || + (aspectRatio ?: 0f) > wideAspect override var imagePage: ImagePage = ProgressPage() @@ -601,11 +660,14 @@ open class WebGpuViewer( } } + /** Read live: these pages are built before the surface has a size, and outlive a rotation. */ + private fun viewportPageWidth(half: Boolean): Int = if (half) pager.state.width / 2 else pager.state.width + /** * 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 { + fun isDualPageMode(): Boolean { if (isContinuous) return false return when (config.dualPageView) { ReaderPreferences.DualPageView.NEVER -> false @@ -618,6 +680,50 @@ open class WebGpuViewer( } } + /** The half a spread opens on: right reading right-to-left, left otherwise. */ + private val anchorPosition get() = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT + + private val partnerPosition get() = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT + + /** + * Which half a page falls on when nothing tags the file: alternating from its spread's start, + * anchor then partner. SINGLE outside dual page mode, so nothing pairs while one page fills + * the viewer. + */ + private fun derivedSpreadPosition(page: ReaderPage): SpreadPosition { + if (!isDualPageMode()) return SpreadPosition.SINGLE + val offset = page.index - spreadStartIndex(page.chapter.chapter.id, page.index) + return if (offset >= 0 && offset % 2 == 0) anchorPosition else partnerPosition + } + + /** + * Where the spread holding [index] starts: just past the last page before it that took one to + * itself, so the page after a detected spread opens the next one instead of inheriting a parity + * that page broke. Defaults to 1 - page 0 is the cover, and pairs with nothing. + */ + private fun spreadStartIndex(chapterId: Long?, index: Int): Int { + val lone = synchronized(lock) { loneIndices[chapterId]?.lower(index) } ?: return 1 + return lone + 1 + } + + /** Registers whether [page] stands alone, for [spreadStartIndex]. Must hold [lock]. */ + private fun noteIfLone(page: ViewerReaderPage) { + val indices = loneIndices.getOrPut(page.page.chapter.chapter.id) { TreeSet() } + if (page.standsAlone) indices.add(page.page.index) else indices.remove(page.page.index) + } + + /** + * Whether these two may share a spread, beyond their positions agreeing. Both tagged is taken + * as read; a pair resting on page order needs the same shape - halves of one sheet scan alike. + * Undecoded pairs anyway, or a loading page draws its ring mid-screen. + */ + private fun canPairShapes(anchor: ViewerReaderPage, partner: ViewerReaderPage): Boolean { + if (anchor.taggedSpreadPosition != null && partner.taggedSpreadPosition != null) return true + val a = anchor.aspectRatio ?: return true + val b = partner.aspectRatio ?: return true + return abs(a - b) <= pairAspectTolerance + } + /** * Check if the given page can form a spread with the next page. * Uses page.spreadPosition to determine: anchor + partner = spread @@ -626,12 +732,10 @@ open class WebGpuViewer( */ private fun canFormSpread(page: ViewerReaderPage): Boolean { if (!isDualPageMode()) return false - val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT - val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT if (page.spreadPosition != anchorPosition) return false val next = page.next as? ViewerReaderPage ?: return false if (next.page.chapter != page.page.chapter) return false - return next.spreadPosition == partnerPosition + return next.spreadPosition == partnerPosition && canPairShapes(page, next) } /** @@ -643,13 +747,12 @@ open class WebGpuViewer( if (!isDualPageMode()) return page if (page !is ViewerReaderPage) return page - val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT - val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT - // If this is a partner page, check if previous is anchor if (page.spreadPosition == partnerPosition) { val prev = page.prev as? ViewerReaderPage ?: return page - if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition) { + if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition && + canPairShapes(prev, page) + ) { return prev } } @@ -679,9 +782,6 @@ open class WebGpuViewer( return imagePage } - val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT - val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT - // Only the anchor side looks for a partner on the next page. A partner-tagged page only // reaches this function directly (rather than being redirected here via // [getSpreadAnchor]) when it has no anchor before it - a lone RIGHT with no preceding @@ -690,7 +790,8 @@ open class WebGpuViewer( val partnerImagePage = if (page.spreadPosition == anchorPosition) { val nextReaderPage = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } nextReaderPage?.imagePage?.takeIf { - nextReaderPage.spreadPosition == partnerPosition + nextReaderPage.spreadPosition == partnerPosition && + canPairShapes(page, nextReaderPage) } } else { null @@ -753,9 +854,11 @@ open class WebGpuViewer( } config.imagePropertyChangedListener = { + val isDual = isDualPageMode() pager.state.apply { - transition = when (config.transitionAnimation) { - TransitionAnimation.DEFAULT -> if (isVertical) TransitionBasic.Vertical else TransitionBasic + transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) { + TransitionAnimation.BASIC -> if (isVertical) TransitionBasic.Vertical else TransitionBasic + TransitionAnimation.FLIP -> TransitionFlip TransitionAnimation.FLIP_LEFT -> TransitionFlipLeft TransitionAnimation.FLIP_RIGHT -> TransitionFlipRight TransitionAnimation.STACK_LEFT -> TransitionStackLeft @@ -770,7 +873,7 @@ open class WebGpuViewer( TransitionAnimation.NONE -> TransitionNone } - when (config.cutoutMode) { + when (if (isDual) config.cutoutModeDual else config.cutoutMode) { ReaderPreferences.CutoutMode.IGNORE -> avoidCutout = false ReaderPreferences.CutoutMode.AVOID -> { avoidCutout = true @@ -782,6 +885,11 @@ open class WebGpuViewer( alwaysAvoidCutout = true } } + + (this as? ImageViewerContinuousState)?.let { + minZoomWidthFraction = config.continuousMinWidth / 100f + scale = minScale + } } synchronized(lock) { @@ -827,6 +935,7 @@ open class WebGpuViewer( it.imagePage.cleanup() } pageCache.clear() + loneIndices.clear() // Notify in case worker is waiting (though it should be interrupted) lock.notifyAll() } @@ -878,14 +987,12 @@ open class WebGpuViewer( 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 - } - - (page.imagePage as? ProgressPage)?.apply { - progress = value / 100f + // Still the placeholder? Evicted or decoded, and there is nothing to fill. + val progressPage = synchronized(lock) { + if (!pageInCache(page)) return@collect + page.imagePage as? ProgressPage ?: return@collect } + progressPage.progress = value / 100f } } @@ -943,29 +1050,24 @@ open class WebGpuViewer( } } - // 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 + // Buffered to read the spread tag, then decoded from the buffer. On the preference, + // not isDualPageMode(): WIDE is portrait-off, and a rotate never re-decodes. + val bytes = if (config.dualPageView != ReaderPreferences.DualPageView.NEVER) { + input.readBytes() + } else { + null + } - page.spreadPosition = if (bytes != null) { + // Left untouched for a file that names no side - [spreadPosition] then derives one. + if (bytes != null) { val tag = Kim.readMetadata(bytes.inputStream(), bytes.size.toLong()) ?.findStringValue(TiffTag.TIFF_TAG_PAGE_NAME) - when (tag) { + page.taggedSpreadPosition = when (tag) { "Left" -> SpreadPosition.LEFT "Right" -> SpreadPosition.RIGHT - // 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 - null -> if (isReversed) { // TODO: heuristics, use image size - if (page.page.index % 2 == 0) SpreadPosition.LEFT else SpreadPosition.RIGHT - } else { - if (page.page.index % 2 == 0) SpreadPosition.RIGHT else SpreadPosition.LEFT - } - + null -> null else -> SpreadPosition.SINGLE } - } else { - SpreadPosition.SINGLE } val dec = ImageDecoder.new(bytes?.inputStream() ?: input) @@ -1035,12 +1137,13 @@ open class WebGpuViewer( if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { val oldImagePage = page.imagePage page.imagePage = imagePage + noteIfLone(page) page.state = PageState.IDLE oldImagePage.cleanup() // Fade up from the placeholder's colour, if that placeholder was on screen - // one that decoded out of view has nothing left to fade from. if (oldImagePage.isOnScreen) imagePage.fadeIn() - if (page.spreadPosition == SpreadPosition.SINGLE) { + if (!isDualPageMode()) { (page.imagePage as? ImagePage.ImageSingle)?.let { if (!applyWideZoomIfNeeded(it)) { applyFitModeAnchor(it) @@ -1073,6 +1176,9 @@ open class WebGpuViewer( image.width.toFloat() / image.height.toFloat(), ) + // not wide enough + if (aspectRatio < 1.1) return false + // Wide page: half the image width is wider than the screen aspect ratio if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false @@ -1334,7 +1440,8 @@ open class WebGpuViewer( val maxX = page.maxX(page.scale) val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x - val x = (currentX - 1 / page.scale).coerceIn(minX, maxX) + val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 + val x = (currentX - c / page.scale).coerceIn(minX, maxX) if (!currentX.closeTo(x)) { page.animateTo(targetX = x, targetY = page.y) @@ -1356,7 +1463,8 @@ open class WebGpuViewer( val maxX = page.maxX(page.scale) val currentX = page.animationJob?.isActive?.let { page.animationTargetX } ?: page.x - val x = (currentX + 1 / page.scale).coerceIn(minX, maxX) + val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 + val x = (currentX + c / page.scale).coerceIn(minX, maxX) if (!currentX.closeTo(x)) { page.animateTo(targetX = x, targetY = page.y) 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 index c0907e21b..edb46c0b5 100644 --- 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 @@ -2,19 +2,39 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webgpu import ca.mpreg.webgpuviewer.ImageViewContinuous import ca.mpreg.webgpuviewer.viewer.ImagePage +import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import kotlin.math.max class WebGpuViewerContinuous(activity: ReaderActivity) : WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) { override val isContinuous: Boolean = true - override val preloadAhead = 3 - override val preloadBehind = 1 + // How many pages the viewport shows depends on the zoom, and a page on screen has to be + // decoded rather than merely reserved - so the window follows what the last frame reached. + override val preloadAhead get() = max(3, state.pagesBelow) + override val preloadBehind get() = max(1, state.pagesAbove) + + // The state reaches MAX_VISIBLE_PAGES either side of the current page whatever the zoom - to + // measure the document's end as well as to draw - and every page in that reach is created on + // demand here. Sized under it, each frame evicts exactly what the next one asks for. + override val cacheSize get() = 2 + 2 * ImageViewerContinuousState.MAX_VISIBLE_PAGES private val state get() = (pager as ImageViewContinuous).state + init { + // Scrolling clear of a transition page is the only point this mode can call the chapter + // before it finished - reaching a page's top comes a screen too early. Reported on every + // change, so scrolling back up over it and down again selects that last page again. + state.onPageScrolledThrough = onScrolledThrough@{ imagePage -> + val chapter = (imagePage as? TransitionPage)?.prevChapter ?: return@onScrolledThrough + val lastPage = chapter.pages?.lastOrNull() ?: return@onScrolledThrough + activity.onPageSelected(lastPage) + } + } + private fun scrollByHalfPage(direction: Int) { state.animateScroll(direction * state.height / 2f) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1947168c2..0b7cc4f2f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,7 +77,7 @@ tapmoc = "0.4.2" unifile = "08f224c8f9" valkyrie = "0.5.2" voyager = "2.2.21-1.10.3" -webgpuviewer = "39" +webgpuviewer = "40" 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 a0f0f6f66..d1840cf5d 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -499,7 +499,8 @@ Disable zoom out WebGPU Transition animation - Default + Transition animation (dual) + Basic Page flip (left) Page flip (right) Stack (left) @@ -512,7 +513,9 @@ Fade Fade to white None + Page flip Display cutout mode + Display cutout mode (dual) Ignore Avoid Shift @@ -520,6 +523,7 @@ Never Always When wide + Min width Delete chapters