Bump webgpuviewer (#3933)

* add disable zoom out

* add hdr

* Fix dual mode page progress and cleanup

* webgpu viewer add page gap setting

* Update libs.versions.toml
This commit is contained in:
w
2026-09-11 22:57:02 -07:00
committed by GitHub
parent 1e054ea14d
commit d17ad1e002
9 changed files with 406 additions and 310 deletions
@@ -35,6 +35,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import ca.mpreg.webgpuviewer.renderer.GainmapInput
import ca.mpreg.webgpuviewer.renderer.Image import ca.mpreg.webgpuviewer.renderer.Image
import ca.mpreg.webgpuviewer.viewer.ImagePage import ca.mpreg.webgpuviewer.viewer.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewer import ca.mpreg.webgpuviewer.viewer.ImageViewer
@@ -180,6 +181,21 @@ fun MangaCoverDialog(
res.height, res.height,
createMipMaps = true, createMipMaps = true,
backgroundColor = 0, backgroundColor = 0,
hdr = res.isHdr,
hdrHeadroom = res.hdrHeadroom,
gainmap = res.gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
},
), ),
) )
} }
@@ -65,7 +65,7 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
} }
} }
if (resolved == ReadingMode.WEBTOON) { if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
val numberFormat = remember { NumberFormat.getPercentInstance() } val numberFormat = remember { NumberFormat.getPercentInstance() }
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState() val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
SliderItem( SliderItem(
@@ -78,6 +78,25 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
}, },
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, 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,
)
} }
} }
@@ -278,8 +297,17 @@ 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 manga by viewModel.mangaFlow.collectAsState()
val viewer by viewModel.viewerFlow.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 isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
@@ -318,6 +346,7 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel)
return return
} }
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
SettingsChipRow(MR.strings.pref_image_scale_type) { SettingsChipRow(MR.strings.pref_image_scale_type) {
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
@@ -378,4 +407,5 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel)
) )
} }
} }
}
} }
@@ -213,6 +213,8 @@ class ReaderPreferences(
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100) val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
// endregion // endregion
enum class FlashColor { enum class FlashColor {
@@ -75,8 +75,8 @@ enum class ReadingMode(
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false) LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false) RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true) VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
WEBTOON -> WebGpuViewerContinuous(activity) WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity) CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference") DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
} }
} }
@@ -61,12 +61,17 @@ class WebGpuConfig(
var dualPageView = ReaderPreferences.DualPageView.NEVER var dualPageView = ReaderPreferences.DualPageView.NEVER
private set private set
var continuousMinWidth = 1 var continuousMinWidth = 100
private set
var zoomOutDisabled = false
private set
var continuousGap = 10
private set private set
init { init {
readerPreferences.readerTheme readerPreferences.readerTheme.register(
.register(
{ {
theme = it theme = it
automaticBackground = it == 3 automaticBackground = it == 3
@@ -74,33 +79,23 @@ class WebGpuConfig(
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.imageScaleType readerPreferences.imageScaleType.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.zoomStart readerPreferences.zoomStart.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
readerPreferences.cropBorders readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigateToPan readerPreferences.navigateToPan.register({ navigateToPan = it })
.register({ navigateToPan = it })
readerPreferences.landscapeZoom readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigationModePager readerPreferences.navigationModePager.register({ navigationMode = it }, { updateNavigation(navigationMode) })
.register({ navigationMode = it }, { updateNavigation(navigationMode) })
readerPreferences.pagerNavInverted readerPreferences.pagerNavInverted.register({ tappingInverted = it }, { navigator.invertMode = it })
.register({ tappingInverted = it }, { navigator.invertMode = it }) readerPreferences.pagerNavInverted.changes().drop(1).onEach { navigationModeChangedListener?.invoke() }
readerPreferences.pagerNavInverted.changes()
.drop(1)
.onEach { navigationModeChangedListener?.invoke() }
.launchIn(scope) .launchIn(scope)
readerPreferences.dualPageSplitPaged readerPreferences.dualPageSplitPaged.register(
.register(
{ dualPageSplit = it }, { dualPageSplit = it },
{ {
imagePropertyChangedListener?.invoke() imagePropertyChangedListener?.invoke()
@@ -108,56 +103,60 @@ class WebGpuConfig(
}, },
) )
readerPreferences.dualPageInvertPaged readerPreferences.dualPageInvertPaged.register(
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() }) { dualPageInvert = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.dualPageRotateToFit readerPreferences.dualPageRotateToFit.register(
.register(
{ dualPageRotateToFit = it }, { dualPageRotateToFit = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.dualPageRotateToFitInvert readerPreferences.dualPageRotateToFitInvert.register(
.register(
{ dualPageRotateToFitInvert = it }, { dualPageRotateToFitInvert = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.transitionAnimation readerPreferences.transitionAnimation.register(
.register(
{ transitionAnimation = it }, { transitionAnimation = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.transitionAnimationDual readerPreferences.transitionAnimationDual.register(
.register(
{ transitionAnimationDual = it }, { transitionAnimationDual = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.cutoutMode readerPreferences.cutoutMode.register(
.register(
{ cutoutMode = it }, { cutoutMode = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.cutoutModeDual readerPreferences.cutoutModeDual.register(
.register(
{ cutoutModeDual = it }, { cutoutModeDual = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.dualPageView readerPreferences.dualPageView.register(
.register(
{ dualPageView = it }, { dualPageView = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.continuousMinWidth readerPreferences.continuousMinWidth.register(
.register(
{ continuousMinWidth = it }, { continuousMinWidth = it },
{ imagePropertyChangedListener?.invoke() }, { imagePropertyChangedListener?.invoke() },
) )
readerPreferences.webtoonDisableZoomOut.register(
{ zoomOutDisabled = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.continuousGap.register(
{ continuousGap = it },
{ imagePropertyChangedListener?.invoke() },
)
} }
private fun zoomTypeFromPreference(value: Int) { private fun zoomTypeFromPreference(value: Int) {
@@ -14,6 +14,7 @@ import ca.mpreg.imagedecoder.ImageDecoder
import ca.mpreg.webgpuviewer.ImageView import ca.mpreg.webgpuviewer.ImageView
import ca.mpreg.webgpuviewer.closeTo import ca.mpreg.webgpuviewer.closeTo
import ca.mpreg.webgpuviewer.draw.TextAlign import ca.mpreg.webgpuviewer.draw.TextAlign
import ca.mpreg.webgpuviewer.renderer.GainmapInput
import ca.mpreg.webgpuviewer.renderer.Image import ca.mpreg.webgpuviewer.renderer.Image
import ca.mpreg.webgpuviewer.transition.TransitionBasic import ca.mpreg.webgpuviewer.transition.TransitionBasic
import ca.mpreg.webgpuviewer.transition.TransitionCube import ca.mpreg.webgpuviewer.transition.TransitionCube
@@ -47,6 +48,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.util.system.createReaderThemeContext import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.util.system.readerBackgroundColor import eu.kanade.tachiyomi.util.system.readerBackgroundColor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
@@ -74,13 +76,22 @@ open class WebGpuViewer(
val readerPreferences by lazy { activity.appGraph.readerPreferences } val readerPreferences by lazy { activity.appGraph.readerPreferences }
private fun readerBackgroundColor(): Int = activity.baseContext.readerBackgroundColor(config.theme) /** Resolved once: render() asks per frame, and createReaderThemeContext builds a Resources. */
@Volatile
private var cachedBackgroundColor: Int? = null
private fun readerOnBackgroundColor(): Int = MaterialColors.getColor( @Volatile
private var cachedOnBackgroundColor: Int? = null
private fun readerBackgroundColor(): Int =
cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme)
.also { cachedBackgroundColor = it }
private fun readerOnBackgroundColor(): Int = cachedOnBackgroundColor ?: MaterialColors.getColor(
activity.createReaderThemeContext(), activity.createReaderThemeContext(),
com.google.android.material.R.attr.colorOnBackground, com.google.android.material.R.attr.colorOnBackground,
Color.WHITE, Color.WHITE,
) ).also { cachedOnBackgroundColor = it }
private val scope = MainScope() private val scope = MainScope()
@@ -90,13 +101,16 @@ open class WebGpuViewer(
} }
private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher() private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher()
// Single lock for all page cache and queue operations // Guards pageCache, decodeQueue, deferredCleanup and chapterPreloadsInFlight.
private val lock = Object() private val lock = Object()
// Page cache - keyed by stable PageKey for O(1) lookup /** Without it the worker parks in [lock].wait() after [destroy], keeping the activity alive. */
@Volatile
private var destroyed = false
private val pageCache = LinkedHashMap<PageKey, ViewerPage>() private val pageCache = LinkedHashMap<PageKey, ViewerPage>()
// Decode queue - pages waiting to be decoded, processed LIFO (last = highest priority) // Processed LIFO - last added is highest priority.
private val decodeQueue = ArrayDeque<ViewerReaderPage>() private val decodeQueue = ArrayDeque<ViewerReaderPage>()
/** /**
@@ -105,6 +119,9 @@ open class WebGpuViewer(
*/ */
private val loneIndices = HashMap<Long?, TreeSet<Int>>() private val loneIndices = HashMap<Long?, TreeSet<Int>>()
/** Chapters [preloadChapterThenRetry] is already waiting on, by id. */
private val chapterPreloadsInFlight = HashSet<Long?>()
/** /**
* Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping * Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping
* for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself. * for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself.
@@ -117,7 +134,6 @@ open class WebGpuViewer(
/** How far two untagged pages' aspect ratios may differ and still pair. */ /** How far two untagged pages' aspect ratios may differ and still pair. */
private val pairAspectTolerance = 0.1f private val pairAspectTolerance = 0.1f
// Stable key types for page identity - data classes provide correct equals/hashCode
private sealed class PageKey { private sealed class PageKey {
data class Reader(val chapterId: Long?, val index: Int) : PageKey() data class Reader(val chapterId: Long?, val index: Int) : PageKey()
data class Transition(val prevId: Long?, val nextId: Long?) : PageKey() data class Transition(val prevId: Long?, val nextId: Long?) : PageKey()
@@ -155,44 +171,39 @@ open class WebGpuViewer(
} }
PageState.QUEUED -> { PageState.QUEUED -> {
// Already queued - move to front if prioritizing
if (prioritize && decodeQueue.remove(page)) { if (prioritize && decodeQueue.remove(page)) {
decodeQueue.addLast(page) decodeQueue.addLast(page)
} }
} }
PageState.LOADING, PageState.DECODING -> { PageState.LOADING, PageState.DECODING -> {}
// Already being processed
}
} }
} }
init { init {
// Decode worker thread - processes pages from the queue
scope.launch(decodeDispatcher) { scope.launch(decodeDispatcher) {
try { try {
while (true) { while (!destroyed) {
// Popped, vetted and marked under one acquisition - no window for an eviction.
val page = synchronized(lock) { val page = synchronized(lock) {
while (decodeQueue.isEmpty()) { while (decodeQueue.isEmpty()) {
if (destroyed) return@launch
lock.wait() lock.wait()
} }
decodeQueue.removeLast().apply { state = PageState.DECODING } val candidate = decodeQueue.removeLast()
} if (pageInCache(candidate) && !candidate.isDecoded) {
candidate.apply { state = PageState.DECODING }
// Verify page is still valid (not evicted and doesn't have a decoded image yet) } else {
val shouldProcess = synchronized(lock) { if (pageInCache(candidate)) candidate.state = PageState.IDLE
pageInCache(page) && page.state == PageState.DECODING && !page.isDecoded null
}
if (!shouldProcess) {
synchronized(lock) {
if (pageInCache(page)) page.state = PageState.IDLE
}
continue
} }
} ?: continue
try { try {
decodeReaderPage(page) decodeReaderPage(page)
} catch (e: CancellationException) {
// Caught below, the loop would park in wait() on a dead scope.
throw e
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" } logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" }
synchronized(lock) { synchronized(lock) {
@@ -201,7 +212,7 @@ open class WebGpuViewer(
val errorMessage = e.message ?: "Failed to decode image" val errorMessage = e.message ?: "Failed to decode image"
page.imagePage = ErrorPage(errorMessage, page.spreadPosition) page.imagePage = ErrorPage(errorMessage, page.spreadPosition)
page.state = PageState.IDLE page.state = PageState.IDLE
oldImagePage.cleanup() cleanupImage(oldImagePage)
page.imagePage.invalidate() page.imagePage.invalidate()
} else { } else {
if (pageInCache(page)) page.state = PageState.IDLE if (pageInCache(page)) page.state = PageState.IDLE
@@ -210,7 +221,9 @@ open class WebGpuViewer(
} }
} }
} catch (_: InterruptedException) { } catch (_: InterruptedException) {
// Normal shutdown // destroy()'s shutdownNow, out of lock.wait().
} catch (_: CancellationException) {
// Scope cancelled with the viewer.
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Decode worker died" } logcat(LogPriority.ERROR, e) { "Decode worker died" }
} }
@@ -222,6 +235,8 @@ open class WebGpuViewer(
*/ */
val config = WebGpuConfig(this, scope, readerPreferences) val config = WebGpuConfig(this, scope, readerPreferences)
// Read from the render and decode threads, via the prevChapter/nextChapter getters.
@Volatile
var viewerChapters: ViewerChapters? = null var viewerChapters: ViewerChapters? = null
val pages: List<ReaderPage>? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages val pages: List<ReaderPage>? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages
@@ -236,14 +251,36 @@ open class WebGpuViewer(
@Volatile @Volatile
private var pinnedFromPage: ImagePage? = null private var pinnedFromPage: ImagePage? = null
/** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */ /** True while [pinnedFromPage] is drawing [image], as itself or as a spread side. */
private fun isPinned(page: ViewerPage): Boolean { private fun isPinnedImage(image: ImagePage): Boolean {
val pinned = pinnedFromPage ?: return false val pinned = pinnedFromPage ?: return false
val image = page.imagePage
if (pinned === image) return true if (pinned === image) return true
return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image) return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image)
} }
/** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */
private fun isPinned(page: ViewerPage): Boolean = isPinnedImage(page.imagePage)
/** Images swapped out while [pinnedFromPage] was still drawing them, i.e. mid page turn. */
private val deferredCleanup = mutableListOf<ImagePage>()
/** Cleans up [image], or defers it while [pinnedFromPage] draws it. Call under [lock]. */
private fun cleanupImage(image: ImagePage) {
if (isPinnedImage(image)) deferredCleanup.add(image) else image.cleanup()
}
/** Releases what [pinnedFromPage] no longer protects. Call under [lock]. */
private fun flushDeferredCleanup() {
val iterator = deferredCleanup.iterator()
while (iterator.hasNext()) {
val image = iterator.next()
if (!isPinnedImage(image)) {
image.cleanup()
iterator.remove()
}
}
}
open val preloadAhead = 3 open val preloadAhead = 3
open val preloadBehind = 2 open val preloadBehind = 2
@@ -253,9 +290,6 @@ open class WebGpuViewer(
*/ */
open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1 open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1
/**
* Page processing state
*/
enum class PageState { enum class PageState {
IDLE, IDLE,
QUEUED, QUEUED,
@@ -277,6 +311,9 @@ open class WebGpuViewer(
pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet() pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet()
if (candidates.isEmpty()) return false if (candidates.isEmpty()) return false
// Read once - the getter measures the viewport.
val reach = cacheSize
fun findNext(page: ViewerPage): ViewerPage? = when (page) { fun findNext(page: ViewerPage): ViewerPage? = when (page) {
is ViewerReaderPage -> { is ViewerReaderPage -> {
val chapterId = page.page.chapter.chapter.id val chapterId = page.page.chapter.chapter.id
@@ -335,7 +372,7 @@ open class WebGpuViewer(
var forward: ViewerPage? = current var forward: ViewerPage? = current
var backward: ViewerPage? = current var backward: ViewerPage? = current
for (i in 0 until cacheSize) { for (i in 0 until reach) {
if (candidates.isEmpty()) break if (candidates.isEmpty()) break
forward = forward?.let { findNext(it) } forward = forward?.let { findNext(it) }
backward = backward?.let { findPrev(it) } backward = backward?.let { findPrev(it) }
@@ -349,8 +386,9 @@ open class WebGpuViewer(
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() // Through the pin: a decode that swapped this page's image leaves its spread unguarded.
toRemove.imagePage.cleanup() (toRemove as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage)
cleanupImage(toRemove.imagePage)
return true return true
} }
@@ -363,7 +401,8 @@ open class WebGpuViewer(
return synchronized(lock) { return synchronized(lock) {
findInCache(key) ?: ViewerReaderPage(page).also { newPage -> findInCache(key) ?: ViewerReaderPage(page).also { newPage ->
pageCache[key] = newPage pageCache[key] = newPage
while (pageCache.size > cacheSize) { val limit = cacheSize
while (pageCache.size > limit) {
if (!evictFarthestPage(referencePage ?: newPage)) break if (!evictFarthestPage(referencePage ?: newPage)) break
} }
} }
@@ -379,7 +418,8 @@ open class WebGpuViewer(
return synchronized(lock) { return synchronized(lock) {
findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage -> findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage ->
pageCache[key] = newPage pageCache[key] = newPage
while (pageCache.size > cacheSize) { val limit = cacheSize
while (pageCache.size > limit) {
if (!evictFarthestPage(referencePage ?: newPage)) break if (!evictFarthestPage(referencePage ?: newPage)) break
} }
} }
@@ -394,7 +434,14 @@ open class WebGpuViewer(
* decode. Gives up after 5 seconds if the chapter never finishes loading. * decode. Gives up after 5 seconds if the chapter never finishes loading.
*/ */
private fun preloadChapterThenRetry(chapter: ReaderChapter) { private fun preloadChapterThenRetry(chapter: ReaderChapter) {
// fetchPage reaches prev/next per frame - unguarded, each frame starts another 5s poll.
val chapterId = chapter.chapter.id
synchronized(lock) {
if (!chapterPreloadsInFlight.add(chapterId)) return
}
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
try {
activity.viewModel.preload(chapter) activity.viewModel.preload(chapter)
repeat(25) { repeat(25) {
if (chapter.state is ReaderChapter.State.Loaded) { if (chapter.state is ReaderChapter.State.Loaded) {
@@ -403,6 +450,9 @@ open class WebGpuViewer(
} }
delay(200.milliseconds) delay(200.milliseconds)
} }
} finally {
synchronized(lock) { chapterPreloadsInFlight.remove(chapterId) }
}
} }
} }
@@ -603,8 +653,7 @@ open class WebGpuViewer(
/** True when nothing may share this page's spread - it is one already. */ /** True when nothing may share this page's spread - it is one already. */
internal val standsAlone: Boolean internal val standsAlone: Boolean
get() = taggedSpreadPosition == SpreadPosition.SINGLE || get() = taggedSpreadPosition == SpreadPosition.SINGLE || (aspectRatio ?: 0f) > wideAspect
(aspectRatio ?: 0f) > wideAspect
override var imagePage: ImagePage = ProgressPage() override var imagePage: ImagePage = ProgressPage()
@@ -724,20 +773,6 @@ open class WebGpuViewer(
return abs(a - b) <= pairAspectTolerance 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
* RTL: RIGHT is anchor, looks for LEFT on next
* LTR: LEFT is anchor, looks for RIGHT on next
*/
private fun canFormSpread(page: ViewerReaderPage): Boolean {
if (!isDualPageMode()) return false
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 && canPairShapes(page, next)
}
/** /**
* Get the anchor page for a spread. * Get the anchor page for a spread.
* RTL: anchor is RIGHT, for LEFT page returns previous RIGHT * RTL: anchor is RIGHT, for LEFT page returns previous RIGHT
@@ -747,7 +782,6 @@ open class WebGpuViewer(
if (!isDualPageMode()) return page if (!isDualPageMode()) return page
if (page !is ViewerReaderPage) return page if (page !is ViewerReaderPage) return page
// If this is a partner page, check if previous is anchor
if (page.spreadPosition == partnerPosition) { if (page.spreadPosition == partnerPosition) {
val prev = page.prev as? ViewerReaderPage ?: return page 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 &&
@@ -757,17 +791,28 @@ open class WebGpuViewer(
} }
} }
// This page is the anchor or standalone
return page return page
} }
/** Who [page] pairs with, or null. One verdict for [buildSpreadPage] and [progressPage]. */
private fun spreadPartner(page: ViewerReaderPage): ViewerReaderPage? {
if (!isDualPageMode()) return null
if (page.spreadPosition != anchorPosition) return null
val next = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } ?: return null
return next.takeIf { it.spreadPosition == partnerPosition && canPairShapes(page, it) }
}
/** Page to report progress for - the spread's lastmost page, not the anchor. */
private fun progressPage(page: ViewerPage): ViewerReaderPage? {
val readerPage = page as? ViewerReaderPage ?: return null
return spreadPartner(readerPage) ?: readerPage
}
private fun buildSpreadPage(page: ViewerPage): ImagePage { private fun buildSpreadPage(page: ViewerPage): ImagePage {
// For ViewerTransitionPage, return its imagePage directly
if (page !is ViewerReaderPage) { if (page !is ViewerReaderPage) {
return page.imagePage return page.imagePage
} }
// Only form spreads in dual page mode
if (!isDualPageMode()) { if (!isDualPageMode()) {
return page.imagePage return page.imagePage
} }
@@ -782,20 +827,9 @@ open class WebGpuViewer(
return imagePage return imagePage
} }
// Only the anchor side looks for a partner on the next page. A partner-tagged page only // Null for a partner reaching here directly, which means no anchor before it - a lone
// reaches this function directly (rather than being redirected here via // RIGHT at a chapter boundary - so it draws alone on its own side.
// [getSpreadAnchor]) when it has no anchor before it - a lone RIGHT with no preceding val partnerImagePage = spreadPartner(page)?.imagePage
// LEFT (or vice versa), e.g. at a chapter boundary - so it renders alone on its own side
// instead of looking anywhere else for a partner.
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 &&
canPairShapes(page, nextReaderPage)
}
} else {
null
}
// LEFT/RIGHT map directly to the spread's left/right slot - independent of reading // LEFT/RIGHT map directly to the spread's left/right slot - independent of reading
// direction, which only decides which side is the anchor for pairing purposes above. // direction, which only decides which side is the anchor for pairing purposes above.
@@ -854,6 +888,10 @@ open class WebGpuViewer(
} }
config.imagePropertyChangedListener = { config.imagePropertyChangedListener = {
// A theme change comes through here.
cachedBackgroundColor = null
cachedOnBackgroundColor = null
val isDual = isDualPageMode() val isDual = isDualPageMode()
pager.state.apply { pager.state.apply {
transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) { transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) {
@@ -887,8 +925,15 @@ open class WebGpuViewer(
} }
(this as? ImageViewerContinuousState)?.let { (this as? ImageViewerContinuousState)?.let {
minZoomWidthFraction = config.continuousMinWidth / 100f homeScale = config.continuousMinWidth / 100f
scale = minScale scale = homeScale
minScale = if (config.zoomOutDisabled) 0f else 0.1f
(this@WebGpuViewer as? WebGpuViewerContinuous)?.let {
if (this@WebGpuViewer.useGap) {
pageGap = config.continuousGap / 100f
}
}
} }
} }
@@ -896,8 +941,8 @@ open class WebGpuViewer(
decodeQueue.clear() decodeQueue.clear()
pageCache.values.forEach { pageCache.values.forEach {
it.state = PageState.IDLE it.state = PageState.IDLE
(it as? ViewerReaderPage)?.spreadPage?.cleanup() (it as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage)
it.imagePage.cleanup() cleanupImage(it.imagePage)
} }
pageCache.clear() pageCache.clear()
@@ -919,44 +964,41 @@ open class WebGpuViewer(
} }
override fun destroy() { override fun destroy() {
// Cancel scope first to stop any new operations // Before the interrupt: taken mid-decode, only the flag stops the worker parking.
destroyed = true
scope.cancel() scope.cancel()
// Shutdown decode executor with interrupt to wake up the worker from wait() // shutdownNow interrupts the worker out of lock.wait().
decodeExecutor.shutdownNow() decodeExecutor.shutdownNow()
decodeDispatcher.close() decodeDispatcher.close()
// Now clean up pages (cleanup() launches fire-and-forget coroutines on Dispatchers.Default)
synchronized(lock) { synchronized(lock) {
decodeQueue.clear() decodeQueue.clear()
// Nothing can still be animating, so the pin has nothing left to protect.
pinnedFromPage = null
pageCache.values.forEach { pageCache.values.forEach {
it.state = PageState.IDLE it.state = PageState.IDLE
(it as? ViewerReaderPage)?.spreadPage?.cleanup() (it as? ViewerReaderPage)?.spreadPage?.cleanup()
it.imagePage.cleanup() it.imagePage.cleanup()
} }
pageCache.clear() pageCache.clear()
deferredCleanup.forEach { it.cleanup() }
deferredCleanup.clear()
loneIndices.clear() loneIndices.clear()
// Notify in case worker is waiting (though it should be interrupted) chapterPreloadsInFlight.clear()
lock.notifyAll() lock.notifyAll()
} }
} }
/**
* Returns the view this viewer uses.
*/
override fun getView(): View = pager override fun getView(): View = pager
/** /** Downloads [page] if needed, then re-queues it for decode once ready. */
* Start loading a page and set up listener to re-queue when ready.
* Called when decode worker encounters a page that isn't downloaded yet.
*/
private fun startPageLoad(page: ViewerReaderPage) { private fun startPageLoad(page: ViewerReaderPage) {
val loader = page.page.chapter.pageLoader ?: run { val loader = page.page.chapter.pageLoader ?: run {
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
return return
} }
// If page is already ready, just re-queue immediately
if (page.page.status == Page.State.Ready) { if (page.page.status == Page.State.Ready) {
synchronized(lock) { synchronized(lock) {
if (pageInCache(page) && !page.isDecoded) { if (pageInCache(page) && !page.isDecoded) {
@@ -969,34 +1011,33 @@ open class WebGpuViewer(
return return
} }
// Transition to LOADING state
synchronized(lock) { synchronized(lock) {
if (!pageInCache(page)) return if (!pageInCache(page)) return
page.state = PageState.LOADING page.state = PageState.LOADING
} }
// Start the download
if (page.page.status == Page.State.Queue) { if (page.page.status == Page.State.Queue) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
loader.loadPage(page.page) loader.loadPage(page.page)
} }
} }
// Set up progress indicator and re-queue when ready
scope.launch { scope.launch {
try { try {
val downloadProgressJob = launch { val downloadProgressJob = launch {
page.page.progressFlow.collect { value -> page.page.progressFlow.collect { value ->
// Still the placeholder? Evicted or decoded, and there is nothing to fill. // Set under the lookup's lock, or an eviction's cleanup() lands between.
val progressPage = synchronized(lock) { synchronized(lock) {
if (!pageInCache(page)) return@collect if (!pageInCache(page)) return@collect
page.imagePage as? ProgressPage ?: return@collect (page.imagePage as? ProgressPage)?.progress = value / 100f
} }
progressPage.progress = value / 100f
} }
} }
page.page.statusFlow.takeWhile { state -> page.page.statusFlow.takeWhile { state ->
// Evicted: stop watching, rather than holding the page until the download ends.
if (!synchronized(lock) { pageInCache(page) }) return@takeWhile false
when (state) { when (state) {
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
is Page.State.Error -> { is Page.State.Error -> {
@@ -1010,7 +1051,6 @@ open class WebGpuViewer(
downloadProgressJob.cancel() downloadProgressJob.cancel()
// Re-queue for decoding if ready
synchronized(lock) { synchronized(lock) {
if (pageInCache(page) && page.state == PageState.LOADING) { if (pageInCache(page) && page.state == PageState.LOADING) {
page.state = PageState.IDLE page.state = PageState.IDLE
@@ -1030,7 +1070,6 @@ open class WebGpuViewer(
} }
private suspend fun decodeReaderPage(page: ViewerReaderPage) { private suspend fun decodeReaderPage(page: ViewerReaderPage) {
// If page isn't downloaded yet, start loading
if (page.page.status != Page.State.Ready) { if (page.page.status != Page.State.Ready) {
startPageLoad(page) startPageLoad(page)
return return
@@ -1042,7 +1081,7 @@ open class WebGpuViewer(
} }
stream.use { input -> stream.use { input ->
// Check if still valid before decoding (not evicted and doesn't have decoded image yet) // Not evicted, and not already decoded by a concurrent call.
synchronized(lock) { synchronized(lock) {
if (!pageInCache(page) || page.isDecoded) { if (!pageInCache(page) || page.isDecoded) {
if (pageInCache(page)) page.state = PageState.IDLE if (pageInCache(page)) page.state = PageState.IDLE
@@ -1051,8 +1090,9 @@ open class WebGpuViewer(
} }
// Buffered to read the spread tag, then decoded from the buffer. On the preference, // 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. // not isDualPageMode(): WIDE is portrait-off, and a rotate never re-decodes. Never in
val bytes = if (config.dualPageView != ReaderPreferences.DualPageView.NEVER) { // continuous, where nothing pairs - that mode reads the stream instead of holding it.
val bytes = if (!isContinuous && config.dualPageView != ReaderPreferences.DualPageView.NEVER) {
input.readBytes() input.readBytes()
} else { } else {
null null
@@ -1070,6 +1110,22 @@ open class WebGpuViewer(
} }
} }
// The decoder hands the map over unapplied - see ImageDecoder.Gainmap - because how
// much of it to use depends on the display, so the viewer applies it.
fun ImageDecoder.DecodeResult.gainmapInput(): GainmapInput? = gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
}
val dec = ImageDecoder.new(bytes?.inputStream() ?: input) val dec = ImageDecoder.new(bytes?.inputStream() ?: input)
val pageCount = dec.pages val pageCount = dec.pages
@@ -1099,26 +1155,50 @@ open class WebGpuViewer(
trimColors = trimColors, trimColors = trimColors,
trimThreshold = 0.15f, trimThreshold = 0.15f,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
hdr = firstFrame.isHdr,
hdrHeadroom = firstFrame.hdrHeadroom,
gainmap = firstFrame.gainmapInput(),
) )
ImagePage.ImageSingle(firstImage) ImagePage.ImageSingle(firstImage)
} else { } else {
val frames = ArrayList<Pair<Image, Int>>(pageCount) val frames = ArrayList<Pair<Image, Int>>(pageCount)
// Built frames hold uploaded textures, and ImageSingle owns the only teardown.
fun discardFrames() {
if (frames.isNotEmpty()) ImagePage.ImageSingle(frames).cleanup()
}
val firstImage = Image( val firstImage = Image(
firstFrame.image, firstFrame.image,
firstFrame.width, firstFrame.width,
firstFrame.height, firstFrame.height,
createMipMaps = false, createMipMaps = false,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
hdr = firstFrame.isHdr,
hdrHeadroom = firstFrame.hdrHeadroom,
gainmap = firstFrame.gainmapInput(),
) )
frames.add(Pair(firstImage, firstFrame.duration)) frames.add(Pair(firstImage, firstFrame.duration))
repeat(pageCount - 1) { try {
(page.imagePage as? ProgressPage)?.apply { for (i in 1 until pageCount) {
progress = (it + 1).toFloat() / pageCount // Under lock: a decode this long gives an eviction's cleanup() time to land.
val stillWanted = synchronized(lock) {
pageInCache(page).also { inCache ->
if (inCache) {
(page.imagePage as? ProgressPage)?.progress = i.toFloat() / pageCount
} }
}
}
// Scrolled past: the frames left are work nothing will draw.
if (!stillWanted) {
discardFrames()
return
}
val frame = dec.decodeNext() val frame = dec.decodeNext()
val image = Image( val image = Image(
frame.image, frame.image,
@@ -1126,9 +1206,16 @@ open class WebGpuViewer(
frame.height, frame.height,
createMipMaps = false, createMipMaps = false,
backgroundColor = firstImage.backgroundColor, backgroundColor = firstImage.backgroundColor,
hdr = frame.isHdr,
hdrHeadroom = frame.hdrHeadroom,
gainmap = frame.gainmapInput(),
) )
frames.add(Pair(image, frame.duration)) frames.add(Pair(image, frame.duration))
} }
} catch (e: Throwable) {
discardFrames()
throw e
}
ImagePage.ImageSingle(frames) ImagePage.ImageSingle(frames)
} }
@@ -1139,7 +1226,7 @@ open class WebGpuViewer(
page.imagePage = imagePage page.imagePage = imagePage
noteIfLone(page) noteIfLone(page)
page.state = PageState.IDLE page.state = PageState.IDLE
oldImagePage.cleanup() cleanupImage(oldImagePage)
// Fade up from the placeholder's colour, if that placeholder was on screen - // 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. // one that decoded out of view has nothing left to fade from.
if (oldImagePage.isOnScreen) imagePage.fadeIn() if (oldImagePage.isOnScreen) imagePage.fadeIn()
@@ -1171,7 +1258,6 @@ open class WebGpuViewer(
val image = page.image ?: return false val image = page.image ?: return false
// if the untrimmed page is wide and the trimmed page is wide
val aspectRatio = min( val aspectRatio = min(
page.trimWidth.toFloat() / page.trimHeight.toFloat(), page.trimWidth.toFloat() / page.trimHeight.toFloat(),
image.width.toFloat() / image.height.toFloat(), image.width.toFloat() / image.height.toFloat(),
@@ -1183,10 +1269,9 @@ open class WebGpuViewer(
// Wide page: half the image width is wider than the screen aspect ratio // Wide page: half the image width is wider than the screen aspect ratio
if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false
// need to set parent for positioning to work
page.parent = pager.state page.parent = pager.state
// Scale to fit half the image width to the full screen width // Half the image width fills the screen width.
page.homeScale = screenW.toFloat() / (page.trimWidth / 2f) page.homeScale = screenW.toFloat() / (page.trimWidth / 2f)
page.scale = page.homeScale page.scale = page.homeScale
@@ -1245,9 +1330,6 @@ open class WebGpuViewer(
page.y = page.homeY page.y = page.homeY
} }
/**
* Queue a page for decoding. If prioritize=true, moves existing queued page to front.
*/
protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) { protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) {
synchronized(lock) { synchronized(lock) {
val cachedPage = findInCache(pageKey(page)) ?: return val cachedPage = findInCache(pageKey(page)) ?: return
@@ -1258,14 +1340,11 @@ open class WebGpuViewer(
} }
protected fun preloadPages(page: ViewerPage) { protected fun preloadPages(page: ViewerPage) {
// Get the canonical page from cache to ensure we're working with current data // page may be a stale copy - resolve the live cache entry.
val key = pageKey(page) val key = pageKey(page)
val cachedPage = synchronized(lock) { findInCache(key) } ?: return val cachedPage = synchronized(lock) { findInCache(key) } ?: return
// Priority order: current (highest), next1, next2, prev1, prev2 (lowest) // prev, then next, then current+partner - the last prioritized call ends up highest.
// Add in reverse for LIFO, current page gets prioritized
// Add prev pages (lowest priority)
val prevPages = mutableListOf<ViewerPage>() val prevPages = mutableListOf<ViewerPage>()
var p: ViewerPage? = cachedPage var p: ViewerPage? = cachedPage
for (i in 0 until preloadBehind) { for (i in 0 until preloadBehind) {
@@ -1274,7 +1353,6 @@ open class WebGpuViewer(
} }
prevPages.asReversed().forEach { preloadPage(it) } prevPages.asReversed().forEach { preloadPage(it) }
// Add next pages (medium priority)
val nextPages = mutableListOf<ViewerPage>() val nextPages = mutableListOf<ViewerPage>()
p = cachedPage p = cachedPage
for (i in 0 until preloadAhead) { for (i in 0 until preloadAhead) {
@@ -1283,8 +1361,6 @@ open class WebGpuViewer(
} }
nextPages.asReversed().forEach { preloadPage(it) } nextPages.asReversed().forEach { preloadPage(it) }
// Add current spread last with priority flag (highest priority in LIFO)
// Also preload the paired page
cachedPage.next?.let { preloadPage(it, prioritize = true) } cachedPage.next?.let { preloadPage(it, prioritize = true) }
preloadPage(cachedPage, prioritize = true) preloadPage(cachedPage, prioritize = true)
} }
@@ -1294,18 +1370,18 @@ open class WebGpuViewer(
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle. * it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/ */
override fun setChapters(chapters: ViewerChapters) { override fun setChapters(chapters: ViewerChapters) {
val pages = chapters.currChapter.pages ?: return // Empty too: lastIndex would be -1, and the requested page is read from it.
val pages = chapters.currChapter.pages
if (pages.isNullOrEmpty()) return
this.viewerChapters = chapters this.viewerChapters = chapters
val requestedIndex = min(chapters.currChapter.requestedPage, pages.lastIndex) // Only when nothing shows yet - re-setting chapters must not move the page.
val requestedPage = pages[requestedIndex] val page = currentPage ?: getPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)])
val anchor = getSpreadAnchor(page)
// Get the page and align to spread anchor if needed currentPage = anchor
val page = currentPage ?: getPage(requestedPage) progressPage(anchor)?.let { activity.onPageSelected(it.page) }
currentPage = getSpreadAnchor(page) preloadPages(anchor)
(currentPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
preloadPages(currentPage!!)
pager.state.apply { pager.state.apply {
onPageChange = onPageChange@{ delta -> onPageChange = onPageChange@{ delta ->
@@ -1330,7 +1406,7 @@ open class WebGpuViewer(
val settled = page val settled = page
this@WebGpuViewer.scope.launch { this@WebGpuViewer.scope.launch {
activity.hideMenu() activity.hideMenu()
(settled as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } progressPage(settled)?.let { activity.onPageSelected(it.page) }
preloadPages(settled) preloadPages(settled)
(settled as? ViewerTransitionPage)?.let { transitionPage -> (settled as? ViewerTransitionPage)?.let { transitionPage ->
@@ -1352,7 +1428,6 @@ open class WebGpuViewer(
override fun moveToPage(page: ReaderPage) { override fun moveToPage(page: ReaderPage) {
// Pin first: resolving a target outside the cached window trims the cache. // Pin first: resolving a target outside the cached window trims the cache.
pinnedFromPage = currentPage?.let { buildSpreadPage(it) } pinnedFromPage = currentPage?.let { buildSpreadPage(it) }
// Get the page and align to spread anchor based on image position
moveToPage(getSpreadAnchor(getPage(page))) moveToPage(getSpreadAnchor(getPage(page)))
} }
@@ -1361,9 +1436,10 @@ open class WebGpuViewer(
// Before preloadPages below trims the cache - see [pinnedFromPage]. // Before preloadPages below trims the cache - see [pinnedFromPage].
val fromSpread = previousPage?.let { buildSpreadPage(it) } val fromSpread = previousPage?.let { buildSpreadPage(it) }
pinnedFromPage = fromSpread pinnedFromPage = fromSpread
synchronized(lock) { flushDeferredCleanup() }
currentPage = newPage currentPage = newPage
(newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } progressPage(newPage)?.let { activity.onPageSelected(it.page) }
preloadPages(newPage) preloadPages(newPage)
(newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage -> (newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage ->
@@ -1417,29 +1493,21 @@ open class WebGpuViewer(
pager.state.animatePageTurn(if (isReversed) direction else -direction) pager.state.animatePageTurn(if (isReversed) direction else -direction)
} }
/**
* Moves to the next page.
*/
fun moveToNext() { fun moveToNext() {
moveRight() moveRight()
} }
/**
* Moves to the previous page.
*/
fun moveToPrevious() { fun moveToPrevious() {
moveLeft() moveLeft()
} }
/**
* Moves to the page at the right.
*/
protected open fun moveRight() { protected open fun moveRight() {
pager.state.getPage(0)?.let { page -> pager.state.getPage(0)?.let { page ->
if (config.navigateToPan) { if (config.navigateToPan) {
val minX = page.minX(page.scale) val minX = page.minX(page.scale)
val maxX = page.maxX(page.scale) val maxX = page.maxX(page.scale)
val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x // Where a running pan is headed, else where it sits.
val currentX = page.animationTargetX ?: page.x
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
val x = (currentX - c / page.scale).coerceIn(minX, maxX) val x = (currentX - c / page.scale).coerceIn(minX, maxX)
@@ -1454,15 +1522,12 @@ open class WebGpuViewer(
} }
} }
/**
* Moves to the page at the left.
*/
protected open fun moveLeft() { protected open fun moveLeft() {
pager.state.getPage(0)?.let { page -> pager.state.getPage(0)?.let { page ->
if (config.navigateToPan) { if (config.navigateToPan) {
val minX = page.minX(page.scale) val minX = page.minX(page.scale)
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.animationTargetX ?: page.x
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
val x = (currentX + c / page.scale).coerceIn(minX, maxX) val x = (currentX + c / page.scale).coerceIn(minX, maxX)
@@ -1477,49 +1542,32 @@ open class WebGpuViewer(
} }
} }
/** /** Target anchor page one spread past [from], in [direction] (positive = forward). */
* Get the target page when navigating by spreads from the given page.
* @param from Starting page
* @param direction Positive = forward in page numbers, negative = backward
* @return Target page or null if navigation not possible
*/
private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? { private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? {
var page = getSpreadAnchor(from) var page = getSpreadAnchor(from)
page = if (direction > 0) { page = if (direction > 0) {
// Going forward (next spread) if (page is ViewerReaderPage && spreadPartner(page) != null) {
if (page is ViewerReaderPage && canFormSpread(page)) {
page.next?.next ?: return null page.next?.next ?: return null
} else { } else {
page.next ?: return null page.next ?: return null
} }
} else { } else {
// Going backward (prev spread)
page.prev ?: return null page.prev ?: return null
} }
return getSpreadAnchor(page) return getSpreadAnchor(page)
} }
/**
* Navigate by spreads from current page.
* @param direction Positive = forward in page numbers, negative = backward
*/
private fun navigateSpread(direction: Int) { private fun navigateSpread(direction: Int) {
val target = currentPage?.let { nextPage(it, direction) } ?: return val target = currentPage?.let { nextPage(it, direction) } ?: return
moveToPage(target) moveToPage(target)
} }
/**
* Moves to the page at the top (or previous).
*/
protected fun moveUp() { protected fun moveUp() {
moveToPrevious() moveToPrevious()
} }
/**
* Moves to the page at the bottom (or next).
*/
protected fun moveDown() { protected fun moveDown() {
moveToNext() moveToNext()
} }
@@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import kotlin.math.max import kotlin.math.max
class WebGpuViewerContinuous(activity: ReaderActivity) : class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) { WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) {
override val isContinuous: Boolean = true override val isContinuous: Boolean = true
+2 -2
View File
@@ -39,7 +39,7 @@ firebase-bom = "34.19.0"
firebase-crashlytics = "3.0.8" firebase-crashlytics = "3.0.8"
flexibleAdapter = "c8013533" flexibleAdapter = "c8013533"
google-services = "4.5.0" google-services = "4.5.0"
image-decoder = "10" image-decoder = "13"
injekt = "91edab2317" injekt = "91edab2317"
jsoup = "1.23.2" jsoup = "1.23.2"
junit = "6.1.3" junit = "6.1.3"
@@ -75,7 +75,7 @@ tapmoc = "0.4.2"
unifile = "08f224c8f9" unifile = "08f224c8f9"
valkyrie = "0.5.2" valkyrie = "0.5.2"
voyager = "2.2.21-1.10.3" voyager = "2.2.21-1.10.3"
webgpuviewer = "40" webgpuviewer = "41"
xmlutil = "1.0.2" xmlutil = "1.0.2"
[libraries] [libraries]
@@ -524,6 +524,7 @@
<string name="dual_page_view_always">Always</string> <string name="dual_page_view_always">Always</string>
<string name="dual_page_view_wide">When wide</string> <string name="dual_page_view_wide">When wide</string>
<string name="pref_continuous_minwidth">Min width</string> <string name="pref_continuous_minwidth">Min width</string>
<string name="pref_continuous_gap">Gap</string>
<!-- Downloads section --> <!-- Downloads section -->
<string name="pref_category_delete_chapters">Delete chapters</string> <string name="pref_category_delete_chapters">Delete chapters</string>