Bump webgpuviewer (#3949)

* Continuous background color

* Use ImageDecoder instead of Kim

* Only get tag when in dual mode

* Update libs.versions.toml

* Make progress circle smaller and animate progress

* Fix long strip showing the menu at ends
This commit is contained in:
w
2026-09-13 14:38:00 -07:00
committed by GitHub
parent 2d1a8bbeff
commit 7418f4e7bc
6 changed files with 244 additions and 183 deletions
-1
View File
@@ -298,7 +298,6 @@ dependencies {
implementation(libs.image.decoder)
implementation(libs.webgpuviewer)
implementation(libs.kim)
// UI libraries
implementation(libs.material)
@@ -13,10 +13,8 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import logcat.LogPriority
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
/**
* A [Decoder] that uses [ImageDecoder] (libvips-based) to decode image formats not supported
@@ -37,18 +35,9 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
}
override suspend fun decode(): DecodeResult {
val decoder = resources.source().use {
try {
ImageDecoder.new(it.inputStream())
} catch (e: ImageDecoder.DecodeException) {
logcat(LogPriority.ERROR, e) { "ImageDecoder.new failed: ${e.message}" }
null
val res = resources.source().use {
ImageDecoder.new(it.inputStream()).use { dec -> dec.decode() }
}
}
check(decoder != null && decoder.pages > 0) { "Failed to initialize decoder" }
val res = decoder.decode()
val srcWidth = res.width
val srcHeight = res.height
@@ -6,6 +6,9 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.spring
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
@@ -33,9 +36,6 @@ 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
import de.stefan_oltmann.kim.format.tiff.constant.TiffTag
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@@ -50,6 +50,7 @@ import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
@@ -83,7 +84,7 @@ open class WebGpuViewer(
@Volatile
private var cachedOnBackgroundColor: Int? = null
private fun readerBackgroundColor(): Int =
protected fun readerBackgroundColor(): Int =
cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme)
.also { cachedBackgroundColor = it }
@@ -93,7 +94,7 @@ open class WebGpuViewer(
Color.WHITE,
).also { cachedOnBackgroundColor = it }
private val scope = MainScope()
protected val scope = MainScope()
// Dedicated thread for decode worker to avoid blocking Dispatchers.Default pool
private val decodeExecutor = Executors.newSingleThreadExecutor { r ->
@@ -147,6 +148,10 @@ open class WebGpuViewer(
private fun findInCache(key: PageKey): ViewerPage? = pageCache[key]
protected fun viewerPageFor(imagePage: ImagePage): ViewerPage? = synchronized(lock) {
pageCache.values.firstOrNull { it.imagePage === imagePage }
}
/** Check if a page is in the cache by identity. O(1) via key lookup. */
private fun pageInCache(page: ViewerPage): Boolean = pageCache[pageKey(page)] === page
@@ -513,10 +518,45 @@ open class WebGpuViewer(
homeScale = 1f
}
var progress: Float = 0f
@Volatile
private var progressValue: Float = 0f
private var progressJob: Job? = null
var progress: Float
get() = progressValue
set(value) {
field = value
val target = value.fastCoerceIn(0f, 1f)
synchronized(this) {
progressJob?.cancel()
progressJob = null
if (destroyed || progressValue == target) return
val scope = scope ?: run {
progressValue = target
invalidate()
return
}
val start = progressValue
progressJob = scope.launch {
animate(
start,
target,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
) { current, _ ->
progressValue = current
invalidate()
}
}
}
}
override fun cleanup() {
super.cleanup()
synchronized(this) {
progressJob?.cancel()
progressJob = null
}
}
var foregroundColor: Int = foregroundColor
@@ -534,9 +574,7 @@ open class WebGpuViewer(
val cx = dst.width * (0.5f + scale * x)
val cy = dst.height * (0.5f + scale * y)
// Off this page's own width, not dst's: a spread half would otherwise draw a ring
// sized for the whole screen, straight over its partner.
val full = width * 0.5f * scale
val full = min(width, height) * 0.25f * scale
circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt())
@@ -925,6 +963,7 @@ open class WebGpuViewer(
}
(this as? ImageViewerContinuousState)?.let {
backgroundColor = readerBackgroundColor()
homeScale = config.continuousMinWidth / 100f
scale = homeScale
minScale = if (config.zoomOutDisabled) 0f else 0.1f
@@ -1089,27 +1128,6 @@ open class WebGpuViewer(
}
}
// 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. Never in
// continuous, where nothing pairs - that mode reads the stream instead of holding it.
val bytes = if (!isContinuous && config.dualPageView != ReaderPreferences.DualPageView.NEVER) {
input.readBytes()
} else {
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)
page.taggedSpreadPosition = when (tag) {
"Left" -> SpreadPosition.LEFT
"Right" -> SpreadPosition.RIGHT
null -> null
else -> SpreadPosition.SINGLE
}
}
// 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 {
@@ -1126,7 +1144,15 @@ open class WebGpuViewer(
)
}
val dec = ImageDecoder.new(bytes?.inputStream() ?: input)
ImageDecoder.new(input).use { dec ->
if (isDualPageMode()) {
page.taggedSpreadPosition = when (dec.getTag("PageName")) {
"Left" -> SpreadPosition.LEFT
"Right" -> SpreadPosition.RIGHT
null -> null
else -> SpreadPosition.SINGLE
}
}
val pageCount = dec.pages
@@ -1245,6 +1271,7 @@ open class WebGpuViewer(
}
}
}
}
private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean {
if (!config.landscapeZoom) return false
@@ -1405,10 +1432,13 @@ open class WebGpuViewer(
// MainScope, not the state's: that one dispatches inside the frame callback.
val settled = page
this@WebGpuViewer.scope.launch {
if (!isContinuous) {
activity.hideMenu()
progressPage(settled)?.let { activity.onPageSelected(it.page) }
}
preloadPages(settled)
if (!isContinuous) {
(settled as? ViewerTransitionPage)?.let { transitionPage ->
if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) {
activity.showMenu()
@@ -1416,6 +1446,7 @@ open class WebGpuViewer(
}
}
}
}
invalidate()
}
@@ -2,9 +2,9 @@ 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 kotlinx.coroutines.launch
import kotlin.math.max
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
@@ -15,24 +15,69 @@ class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = fal
// 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)
override val preloadBehind get() = max(2, 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
override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
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)
state.backgroundColor = readerBackgroundColor()
state.onViewport = { readThrough ->
val pageChanged = readThrough !== lastReadThrough
if (pageChanged) {
lastReadThrough = readThrough
readThrough?.let { select(it) }
}
val page0 = state.getPage(0)
val edge = (page0 is TransitionPage && page0.prevChapter == null) ||
(readThrough is TransitionPage && readThrough.nextChapter == null)
val first = wasAtEdge == null
val edgeChanged = edge != wasAtEdge
wasAtEdge = edge
if (edge) {
if (edgeChanged) scope.launch { activity.showMenu() }
} else if (!first && (edgeChanged || pageChanged)) {
scope.launch { activity.hideMenu() }
}
}
}
/** Null until the first frame, so opening mid-document neither shows nor hides. */
@Volatile
private var wasAtEdge: Boolean? = null
@Volatile
private var lastReadThrough: ImagePage? = null
/**
* Mark [imagePage] read - its bottom has cleared the viewport, which is later than
* onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to
* reveal the page above would otherwise walk progress back past pages still on screen.
*/
private fun select(imagePage: ImagePage) {
val page = readerPageFor(imagePage) ?: return
scope.launch { activity.onPageSelected(page) }
}
private fun readerPageFor(imagePage: ImagePage): ReaderPage? {
// Off the page itself: a transition page is never currentPage (the scroll walk stops
// before it), so it is the first thing a preload evicts.
if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull()
// Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted
// means scrolled well clear of, so there is no progress left to report.
return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page
}
override fun destroy() {
state.onViewport = null
lastReadThrough = null
wasAtEdge = null
super.destroy()
}
private fun scrollByHalfPage(direction: Int) {
@@ -45,6 +90,8 @@ class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = fal
override fun moveToPage(page: ReaderPage) {
super.moveToPage(page)
wasAtEdge = null
lastReadThrough = null
// Also for a jump to the page already showing, which turns nothing to slide in.
state.resetScroll()
}
@@ -47,8 +47,8 @@ object ImageUtil {
fun findImageType(stream: InputStream): ImageType? {
return try {
val decoder = ImageDecoder.new(stream)
when (decoder.format) {
val format = ImageDecoder.new(stream).use { dec -> dec.format }
when (format) {
"jpeg" -> ImageType.JPEG
"png" -> ImageType.PNG
"webp" -> ImageType.WEBP
@@ -76,8 +76,7 @@ object ImageUtil {
ImageType.GIF -> true
ImageType.WEBP, ImageType.HEIF -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
val decoder = ImageDecoder.new(source.peek().inputStream())
decoder.pages > 1
ImageDecoder.new(source.peek().inputStream()).use { dec -> dec.pages > 1 }
}
else -> false
@@ -303,19 +302,17 @@ object ImageUtil {
* Algorithm for determining what background to accompany a comic/manga page
*/
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val decoder = try {
ImageDecoder.new(imageStream)
val image = try {
ImageDecoder.new(imageStream).use { it.decode() }.let { res ->
createBitmap(res.width, res.height).also { bitmap ->
res.image.rewind()
bitmap.copyPixelsFromBuffer(res.image)
}
}
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" }
null
}
val result = decoder?.decode()
val image = result?.let {
createBitmap(it.width, it.height).also { bitmap ->
it.image.rewind()
bitmap.copyPixelsFromBuffer(it.image)
}
}
val whiteColor = Color.WHITE
if (image == null) return whiteColor.toDrawable()
+2 -4
View File
@@ -39,11 +39,10 @@ firebase-bom = "34.19.0"
firebase-crashlytics = "3.0.8"
flexibleAdapter = "5.1.0"
google-services = "4.5.0"
image-decoder = "13"
image-decoder = "14"
injekt = "1.16.1"
jsoup = "1.23.2"
junit = "6.1.3"
kim = "0.40.0"
kotest-assertions = "6.2.5"
kotlin-gradle = "2.4.20"
kotlinx-coroutines = "1.11.0"
@@ -75,7 +74,7 @@ tapmoc = "0.4.2"
unifile = "08f224c8f9"
valkyrie = "0.5.2"
voyager = "2.2.21-1.10.3"
webgpuviewer = "41"
webgpuviewer = "46"
xmlutil = "1.0.2"
[libraries]
@@ -142,7 +141,6 @@ injekt = { module = "uy.kohesive.injekt:injekt-core", version.ref = "injekt" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
kim = { module = "de.stefan-oltmann:kim", version.ref = "kim" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest-assertions" }
kotlin-compose-compiler-gradle = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin-gradle" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle" }