Bump webgpuviewer, fix navigation directions and long strip bounds (#3870)

* Keep previous page when jumping pages

* Fix navigation directions

* fix continuous jump after navigate

* webgpuviewer config

* Fade in loaded pages

* async onPageChange

* Update libs.versions.toml
This commit is contained in:
w
2026-08-28 10:42:37 -07:00
committed by GitHub
parent 5fadea61e7
commit bb24e904a9
5 changed files with 180 additions and 65 deletions
@@ -31,6 +31,7 @@ import java.text.NumberFormat
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.pref_category_for_this_series) HeadingItem(MR.strings.pref_category_for_this_series)
val manga by viewModel.mangaFlow.collectAsState() val manga by viewModel.mangaFlow.collectAsState()
val viewer by viewModel.viewerFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) { SettingsChipRow(MR.strings.pref_category_reading_mode) {
@@ -43,22 +44,24 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
} }
} }
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() if (viewer is WebGpuViewer) {
val resolved = ReadingMode.fromPreference( val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
when { val resolved = ReadingMode.fromPreference(
readingMode == ReadingMode.DEFAULT -> default when {
else -> manga?.readingMode?.toInt() ?: default 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() if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
SettingsChipRow(MR.strings.pref_dual_page_view) { val dualPageView by viewModel.preferences.dualPageView.collectAsState()
ReaderPreferences.DualPageView.entries.map { SettingsChipRow(MR.strings.pref_dual_page_view) {
FilterChip( ReaderPreferences.DualPageView.entries.map {
selected = it == dualPageView, FilterChip(
onClick = { viewModel.preferences.dualPageView.set(it) }, selected = it == dualPageView,
label = { Text(stringResource(it.titleRes)) }, onClick = { viewModel.preferences.dualPageView.set(it) },
) label = { Text(stringResource(it.titleRes)) },
)
}
} }
} }
} }
@@ -74,14 +77,10 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
} }
} }
val viewer by viewModel.viewerFlow.collectAsState() when (viewer) {
if (viewer is WebtoonViewer) { is WebtoonViewer -> WebtoonViewerSettings(viewModel)
WebtoonViewerSettings(viewModel) is WebGpuViewer -> WebGpuViewerSettings(viewModel)
} else { else -> PagerViewerSettings(viewModel)
PagerViewerSettings(viewModel)
if (viewer is WebGpuViewer) {
WebGpuViewerSettings(viewModel)
}
} }
} }
@@ -264,9 +263,57 @@ private fun ColumnScope.TapZonesItems(
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.webgpu_viewer) HeadingItem(MR.strings.webgpu_viewer)
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,
)
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() val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState()
SettingsChipRow(MR.strings.pref_transition_animation) { SettingsChipRow(MR.strings.pref_transition_animation) {
ReaderPreferences.TransitionAnimation.entries.map { ReaderPreferences.TransitionAnimation.entries.forEach {
FilterChip( FilterChip(
selected = it == transitionAnimation, selected = it == transitionAnimation,
onClick = { viewModel.preferences.transitionAnimation.set(it) }, onClick = { viewModel.preferences.transitionAnimation.set(it) },
@@ -277,7 +324,7 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel)
val cutoutMode by viewModel.preferences.cutoutMode.collectAsState() val cutoutMode by viewModel.preferences.cutoutMode.collectAsState()
SettingsChipRow(MR.strings.pref_cutout_mode) { SettingsChipRow(MR.strings.pref_cutout_mode) {
ReaderPreferences.CutoutMode.entries.map { ReaderPreferences.CutoutMode.entries.forEach {
FilterChip( FilterChip(
selected = it == cutoutMode, selected = it == cutoutMode,
onClick = { viewModel.preferences.cutoutMode.set(it) }, onClick = { viewModel.preferences.cutoutMode.set(it) },
@@ -298,6 +298,13 @@ class ReaderPreferences(
MR.strings.scale_type_smart_fit, MR.strings.scale_type_smart_fit,
) )
val ImageScaleTypeWebGpuViewer = listOf(
MR.strings.scale_type_fit_screen,
MR.strings.scale_type_fit_width,
MR.strings.scale_type_fit_height,
MR.strings.scale_type_original_size,
)
val ZoomStart = listOf( val ZoomStart = listOf(
MR.strings.zoom_start_automatic, MR.strings.zoom_start_automatic,
MR.strings.zoom_start_left, MR.strings.zoom_start_left,
@@ -214,6 +214,21 @@ open class WebGpuViewer(
@Volatile @Volatile
var currentPage: ViewerPage? = null var currentPage: ViewerPage? = null
/**
* What a running page turn animates away from, kept out of [evictFarthestPage]'s reach - a
* jump preloads enough pages to evict it. Replaced by the next turn's rather than cleared.
*/
@Volatile
private var pinnedFromPage: ImagePage? = null
/** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */
private fun isPinned(page: ViewerPage): Boolean {
val pinned = pinnedFromPage ?: return false
val image = page.imagePage
if (pinned === image) return true
return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image)
}
open val preloadAhead = 3 open val preloadAhead = 3
open val preloadBehind = 2 open val preloadBehind = 2
@@ -231,12 +246,18 @@ open class WebGpuViewer(
/** /**
* Evicts the page farthest from reference. Must be called while holding lock. * Evicts the page farthest from reference. Must be called while holding lock.
*
* Never evicts [reference], [currentPage] or what [pinnedFromPage] draws. Returns false when
* nothing was evictable, so a trim loop stops instead of spinning.
*
* @param reference The page to use as reference (defaults to currentPage) * @param reference The page to use as reference (defaults to currentPage)
*/ */
private fun evictFarthestPage(reference: ViewerPage? = null) { private fun evictFarthestPage(reference: ViewerPage? = null): Boolean {
val current = reference ?: currentPage ?: return val current = reference ?: currentPage ?: return false
val candidates = pageCache.values.filter { it !== current }.toMutableSet() val candidates = pageCache.values
if (candidates.isEmpty()) return .filter { it !== current && it !== currentPage && !isPinned(it) }
.toMutableSet()
if (candidates.isEmpty()) return false
fun findNext(page: ViewerPage): ViewerPage? = when (page) { fun findNext(page: ViewerPage): ViewerPage? = when (page) {
is ViewerReaderPage -> { is ViewerReaderPage -> {
@@ -305,13 +326,14 @@ open class WebGpuViewer(
if (backward != null && candidates.remove(backward)) farthest = backward if (backward != null && candidates.remove(backward)) farthest = backward
} }
val toRemove = candidates.firstOrNull() ?: farthest ?: return val toRemove = candidates.firstOrNull() ?: farthest ?: return false
pageCache.remove(pageKey(toRemove)) pageCache.remove(pageKey(toRemove))
decodeQueue.remove(toRemove) decodeQueue.remove(toRemove)
toRemove.state = PageState.IDLE toRemove.state = PageState.IDLE
(toRemove as? ViewerReaderPage)?.spreadPage?.cleanup() (toRemove as? ViewerReaderPage)?.spreadPage?.cleanup()
toRemove.imagePage.cleanup() toRemove.imagePage.cleanup()
return true
} }
/** /**
@@ -324,7 +346,7 @@ open class WebGpuViewer(
findInCache(key) ?: ViewerReaderPage(page).also { newPage -> findInCache(key) ?: ViewerReaderPage(page).also { newPage ->
pageCache[key] = newPage pageCache[key] = newPage
while (pageCache.size > cacheSize) { while (pageCache.size > cacheSize) {
evictFarthestPage(referencePage ?: newPage) if (!evictFarthestPage(referencePage ?: newPage)) break
} }
} }
} }
@@ -340,7 +362,7 @@ open class WebGpuViewer(
findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage -> findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage ->
pageCache[key] = newPage pageCache[key] = newPage
while (pageCache.size > cacheSize) { while (pageCache.size > cacheSize) {
evictFarthestPage(referencePage ?: newPage) if (!evictFarthestPage(referencePage ?: newPage)) break
} }
} }
} }
@@ -367,7 +389,7 @@ open class WebGpuViewer(
} }
inner class ErrorPage internal constructor( inner class ErrorPage internal constructor(
var message: String, message: String,
spreadPosition: SpreadPosition = SpreadPosition.SINGLE, spreadPosition: SpreadPosition = SpreadPosition.SINGLE,
) : ImagePage.Render( ) : ImagePage.Render(
if (spreadPosition == SpreadPosition.SINGLE) pager.state.width else pager.state.width / 2, if (spreadPosition == SpreadPosition.SINGLE) pager.state.width else pager.state.width / 2,
@@ -379,6 +401,12 @@ open class WebGpuViewer(
homeScale = 1f homeScale = 1f
} }
var message: String = message
set(value) {
field = value
invalidate()
}
override val backgroundColor: Int = readerBackgroundColor() override val backgroundColor: Int = readerBackgroundColor()
override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) {
@@ -403,18 +431,28 @@ open class WebGpuViewer(
} }
} }
inner class ProgressPage(var foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render( inner class ProgressPage(foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render(
if (!isDualPageMode()) pager.state.width else pager.state.width / 2, if (!isDualPageMode()) pager.state.width else pager.state.width / 2,
pager.state.height, pager.state.height,
) { ) {
var progress: Float = 0f
init { init {
minScale = 1f minScale = 1f
maxScale = 1f maxScale = 1f
homeScale = 1f homeScale = 1f
} }
var progress: Float = 0f
set(value) {
field = value
invalidate()
}
var foregroundColor: Int = foregroundColor
set(value) {
field = value
invalidate()
}
override val backgroundColor: Int = readerBackgroundColor() override val backgroundColor: Int = readerBackgroundColor()
override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) {
@@ -702,8 +740,8 @@ open class WebGpuViewer(
NavigationRegion.MENU -> activity.toggleMenu() NavigationRegion.MENU -> activity.toggleMenu()
NavigationRegion.NEXT -> if (isReversed) moveToPrevious() else moveToNext() NavigationRegion.NEXT -> if (isReversed) moveToPrevious() else moveToNext()
NavigationRegion.PREV -> if (isReversed) moveToNext() else moveToPrevious() NavigationRegion.PREV -> if (isReversed) moveToNext() else moveToPrevious()
NavigationRegion.RIGHT -> if (isReversed) moveLeft() else moveRight() NavigationRegion.RIGHT -> moveRight()
NavigationRegion.LEFT -> if (isReversed) moveRight() else moveLeft() NavigationRegion.LEFT -> moveLeft()
} }
} }
@@ -847,7 +885,6 @@ open class WebGpuViewer(
(page.imagePage as? ProgressPage)?.apply { (page.imagePage as? ProgressPage)?.apply {
progress = value / 100f progress = value / 100f
invalidate()
} }
} }
} }
@@ -979,7 +1016,6 @@ open class WebGpuViewer(
repeat(pageCount - 1) { repeat(pageCount - 1) {
(page.imagePage as? ProgressPage)?.apply { (page.imagePage as? ProgressPage)?.apply {
progress = (it + 1).toFloat() / pageCount progress = (it + 1).toFloat() / pageCount
invalidate()
} }
val frame = dec.decodeNext() val frame = dec.decodeNext()
val image = Image( val image = Image(
@@ -1001,6 +1037,9 @@ open class WebGpuViewer(
page.imagePage = imagePage page.imagePage = imagePage
page.state = PageState.IDLE page.state = PageState.IDLE
oldImagePage.cleanup() 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 (page.spreadPosition == SpreadPosition.SINGLE) {
(page.imagePage as? ImagePage.ImageSingle)?.let { (page.imagePage as? ImagePage.ImageSingle)?.let {
if (!applyWideZoomIfNeeded(it)) { if (!applyWideZoomIfNeeded(it)) {
@@ -1163,8 +1202,6 @@ open class WebGpuViewer(
pager.state.apply { pager.state.apply {
onPageChange = onPageChange@{ delta -> onPageChange = onPageChange@{ delta ->
activity.hideMenu()
// The viewer already showed the page at fetchPage(delta). // The viewer already showed the page at fetchPage(delta).
// We need to update currentPage to match that. // We need to update currentPage to match that.
val current = currentPage ?: return@onPageChange val current = currentPage ?: return@onPageChange
@@ -1176,13 +1213,23 @@ open class WebGpuViewer(
page = nextPage(page, step) ?: return@onPageChange page = nextPage(page, step) ?: return@onPageChange
} }
// Synchronous, since the viewer walks getPage() from here - stale, and the next
// scroll step crosses the same boundary again.
currentPage = page currentPage = page
(page as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
preloadPages(page)
(page as? ViewerTransitionPage)?.let { ViewerTransitionPage -> // The rest ran here too, on the animation thread under the viewer's scroll lock.
if (ViewerTransitionPage.prevChapter == null || ViewerTransitionPage.nextChapter == null) { // Posted in order, so nothing is skipped or reordered - and on this viewer's own
activity.showMenu() // MainScope, not the state's: that one dispatches inside the frame callback.
val settled = page
this@WebGpuViewer.scope.launch {
activity.hideMenu()
(settled as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
preloadPages(settled)
(settled as? ViewerTransitionPage)?.let { transitionPage ->
if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) {
activity.showMenu()
}
} }
} }
} }
@@ -1196,12 +1243,17 @@ open class WebGpuViewer(
* In dual page mode, aligns to the start of the spread containing the page. * In dual page mode, aligns to the start of the spread containing the page.
*/ */
override fun moveToPage(page: ReaderPage) { override fun moveToPage(page: ReaderPage) {
// Pin first: resolving a target outside the cached window trims the cache.
pinnedFromPage = currentPage?.let { buildSpreadPage(it) }
// Get the page and align to spread anchor based on image position // Get the page and align to spread anchor based on image position
moveToPage(getSpreadAnchor(getPage(page))) moveToPage(getSpreadAnchor(getPage(page)))
} }
private fun moveToPage(newPage: ViewerPage) { private fun moveToPage(newPage: ViewerPage) {
val previousPage = currentPage val previousPage = currentPage
// Before preloadPages below trims the cache - see [pinnedFromPage].
val fromSpread = previousPage?.let { buildSpreadPage(it) }
pinnedFromPage = fromSpread
currentPage = newPage currentPage = newPage
(newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } (newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
@@ -1245,14 +1297,19 @@ open class WebGpuViewer(
else -> 0 else -> 0
} }
if (direction != 0) { if (direction != 0 && fromSpread != null) {
pager.state.transitionFromPage = buildSpreadPage(previousPage) animateTurn(direction, fromSpread)
pager.state.animatePageTurn(if (isReversed) direction else -direction)
} else { } else {
pager.state.invalidate() pager.state.invalidate()
} }
} }
/** How a [moveToPage] turn is shown. [direction] is 1 forward through the pages, -1 back. */
protected open fun animateTurn(direction: Int, fromSpread: ImagePage) {
pager.state.transitionFromPage = fromSpread
pager.state.animatePageTurn(if (isReversed) direction else -direction)
}
/** /**
* Moves to the next page. * Moves to the next page.
*/ */
@@ -1277,8 +1334,7 @@ open class WebGpuViewer(
val maxX = page.maxX(page.scale) val maxX = page.maxX(page.scale)
val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x
val c = if (isReversed) -1 else 1 val x = (currentX - 1 / page.scale).coerceIn(minX, maxX)
val x = (currentX - c / page.scale).coerceIn(minX, maxX)
if (!currentX.closeTo(x)) { if (!currentX.closeTo(x)) {
page.animateTo(targetX = x, targetY = page.y) page.animateTo(targetX = x, targetY = page.y)
@@ -1286,7 +1342,7 @@ open class WebGpuViewer(
} }
} }
navigateSpread(1) navigateSpread(if (isReversed) -1 else 1)
} }
} }
@@ -1300,8 +1356,7 @@ open class WebGpuViewer(
val maxX = page.maxX(page.scale) val maxX = page.maxX(page.scale)
val currentX = page.animationJob?.isActive?.let { page.animationTargetX } ?: page.x val currentX = page.animationJob?.isActive?.let { page.animationTargetX } ?: page.x
val c = if (isReversed) -1 else 1 val x = (currentX + 1 / page.scale).coerceIn(minX, maxX)
val x = (currentX + c / page.scale).coerceIn(minX, maxX)
if (!currentX.closeTo(x)) { if (!currentX.closeTo(x)) {
page.animateTo(targetX = x, targetY = page.y) page.animateTo(targetX = x, targetY = page.y)
@@ -1309,7 +1364,7 @@ open class WebGpuViewer(
} }
} }
navigateSpread(-1) navigateSpread(if (isReversed) 1 else -1)
} }
} }
@@ -1372,7 +1427,7 @@ open class WebGpuViewer(
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false return false
} else if (isUp) { } else if (isUp) {
if (!config.volumeKeysInverted) moveDown() else moveUp() if (!config.volumeKeysInverted.xor(isReversed)) moveDown() else moveUp()
} }
} }
@@ -1380,7 +1435,7 @@ open class WebGpuViewer(
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false return false
} else if (isUp) { } else if (isUp) {
if (!config.volumeKeysInverted) moveUp() else moveDown() if (!config.volumeKeysInverted.xor(isReversed)) moveUp() else moveDown()
} }
} }
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import ca.mpreg.webgpuviewer.ImageViewContinuous import ca.mpreg.webgpuviewer.ImageViewContinuous
import ca.mpreg.webgpuviewer.viewer.ImagePage
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@@ -9,13 +10,13 @@ class WebGpuViewerContinuous(activity: ReaderActivity) :
override val isContinuous: Boolean = true override val isContinuous: Boolean = true
override val preloadAhead = 2 override val preloadAhead = 3
override val preloadBehind = 1 override val preloadBehind = 1
private val state get() = (pager as ImageViewContinuous).state
private fun scrollByHalfPage(direction: Int) { private fun scrollByHalfPage(direction: Int) {
val state = (pager as ImageViewContinuous).state state.animateScroll(direction * state.height / 2f)
val totalDistance = direction * state.height / 2f
state.animateScroll(totalDistance)
} }
override fun moveRight() = scrollByHalfPage(1) override fun moveRight() = scrollByHalfPage(1)
@@ -24,6 +25,11 @@ class WebGpuViewerContinuous(activity: ReaderActivity) :
override fun moveToPage(page: ReaderPage) { override fun moveToPage(page: ReaderPage) {
super.moveToPage(page) super.moveToPage(page)
(pager as ImageViewContinuous).state.scrollY = 0f // Also for a jump to the page already showing, which turns nothing to slide in.
state.resetScroll()
}
override fun animateTurn(direction: Int, fromSpread: ImagePage) {
state.animateSlideIn(direction)
} }
} }
+1 -1
View File
@@ -76,7 +76,7 @@ swipe = "1.3.0"
tapmoc = "0.4.2" tapmoc = "0.4.2"
unifile = "08f224c8f9" unifile = "08f224c8f9"
voyager = "2.2.21-1.10.3" voyager = "2.2.21-1.10.3"
webgpuviewer = "38" webgpuviewer = "39"
xmlutil = "1.0.2" xmlutil = "1.0.2"
[libraries] [libraries]