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.image.decoder)
implementation(libs.webgpuviewer) implementation(libs.webgpuviewer)
implementation(libs.kim)
// UI libraries // UI libraries
implementation(libs.material) implementation(libs.material)
@@ -13,10 +13,8 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import logcat.LogPriority
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil 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 * 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 { override suspend fun decode(): DecodeResult {
val decoder = resources.source().use { val res = resources.source().use {
try { ImageDecoder.new(it.inputStream()).use { dec -> dec.decode() }
ImageDecoder.new(it.inputStream())
} catch (e: ImageDecoder.DecodeException) {
logcat(LogPriority.ERROR, e) { "ImageDecoder.new failed: ${e.message}" }
null
} }
}
check(decoder != null && decoder.pages > 0) { "Failed to initialize decoder" }
val res = decoder.decode()
val srcWidth = res.width val srcWidth = res.width
val srcHeight = res.height val srcHeight = res.height
@@ -6,6 +6,9 @@ import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View 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.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn 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.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState
import com.google.android.material.color.MaterialColors 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.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter 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 eu.kanade.tachiyomi.util.system.readerBackgroundColor
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@@ -83,7 +84,7 @@ open class WebGpuViewer(
@Volatile @Volatile
private var cachedOnBackgroundColor: Int? = null private var cachedOnBackgroundColor: Int? = null
private fun readerBackgroundColor(): Int = protected fun readerBackgroundColor(): Int =
cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme) cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme)
.also { cachedBackgroundColor = it } .also { cachedBackgroundColor = it }
@@ -93,7 +94,7 @@ open class WebGpuViewer(
Color.WHITE, Color.WHITE,
).also { cachedOnBackgroundColor = it } ).also { cachedOnBackgroundColor = it }
private val scope = MainScope() protected val scope = MainScope()
// Dedicated thread for decode worker to avoid blocking Dispatchers.Default pool // Dedicated thread for decode worker to avoid blocking Dispatchers.Default pool
private val decodeExecutor = Executors.newSingleThreadExecutor { r -> private val decodeExecutor = Executors.newSingleThreadExecutor { r ->
@@ -147,6 +148,10 @@ open class WebGpuViewer(
private fun findInCache(key: PageKey): ViewerPage? = pageCache[key] 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. */ /** 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 private fun pageInCache(page: ViewerPage): Boolean = pageCache[pageKey(page)] === page
@@ -513,10 +518,45 @@ open class WebGpuViewer(
homeScale = 1f homeScale = 1f
} }
var progress: Float = 0f @Volatile
private var progressValue: Float = 0f
private var progressJob: Job? = null
var progress: Float
get() = progressValue
set(value) { 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() 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 var foregroundColor: Int = foregroundColor
@@ -534,9 +574,7 @@ open class WebGpuViewer(
val cx = dst.width * (0.5f + scale * x) val cx = dst.width * (0.5f + scale * x)
val cy = dst.height * (0.5f + scale * y) 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 val full = min(width, height) * 0.25f * scale
// sized for the whole screen, straight over its partner.
val full = width * 0.5f * scale
circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt()) circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt())
@@ -925,6 +963,7 @@ open class WebGpuViewer(
} }
(this as? ImageViewerContinuousState)?.let { (this as? ImageViewerContinuousState)?.let {
backgroundColor = readerBackgroundColor()
homeScale = config.continuousMinWidth / 100f homeScale = config.continuousMinWidth / 100f
scale = homeScale scale = homeScale
minScale = if (config.zoomOutDisabled) 0f else 0.1f 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 // 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. // much of it to use depends on the display, so the viewer applies it.
fun ImageDecoder.DecodeResult.gainmapInput(): GainmapInput? = gainmap?.let { 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 val pageCount = dec.pages
@@ -1245,6 +1271,7 @@ open class WebGpuViewer(
} }
} }
} }
}
private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean { private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean {
if (!config.landscapeZoom) return false if (!config.landscapeZoom) return false
@@ -1405,10 +1432,13 @@ open class WebGpuViewer(
// MainScope, not the state's: that one dispatches inside the frame callback. // MainScope, not the state's: that one dispatches inside the frame callback.
val settled = page val settled = page
this@WebGpuViewer.scope.launch { this@WebGpuViewer.scope.launch {
if (!isContinuous) {
activity.hideMenu() activity.hideMenu()
progressPage(settled)?.let { activity.onPageSelected(it.page) } progressPage(settled)?.let { activity.onPageSelected(it.page) }
}
preloadPages(settled) preloadPages(settled)
if (!isContinuous) {
(settled as? ViewerTransitionPage)?.let { transitionPage -> (settled as? ViewerTransitionPage)?.let { transitionPage ->
if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) {
activity.showMenu() activity.showMenu()
@@ -1416,6 +1446,7 @@ open class WebGpuViewer(
} }
} }
} }
}
invalidate() invalidate()
} }
@@ -2,9 +2,9 @@ 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 ca.mpreg.webgpuviewer.viewer.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState
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
import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) : 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 // 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. // decoded rather than merely reserved - so the window follows what the last frame reached.
override val preloadAhead get() = max(3, state.pagesBelow) 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 override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
// 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 private val state get() = (pager as ImageViewContinuous).state
init { init {
// Scrolling clear of a transition page is the only point this mode can call the chapter state.backgroundColor = readerBackgroundColor()
// 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.onViewport = { readThrough ->
state.onPageScrolledThrough = onScrolledThrough@{ imagePage -> val pageChanged = readThrough !== lastReadThrough
val chapter = (imagePage as? TransitionPage)?.prevChapter ?: return@onScrolledThrough if (pageChanged) {
val lastPage = chapter.pages?.lastOrNull() ?: return@onScrolledThrough lastReadThrough = readThrough
activity.onPageSelected(lastPage) 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) { private fun scrollByHalfPage(direction: Int) {
@@ -45,6 +90,8 @@ class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = fal
override fun moveToPage(page: ReaderPage) { override fun moveToPage(page: ReaderPage) {
super.moveToPage(page) super.moveToPage(page)
wasAtEdge = null
lastReadThrough = null
// Also for a jump to the page already showing, which turns nothing to slide in. // Also for a jump to the page already showing, which turns nothing to slide in.
state.resetScroll() state.resetScroll()
} }
@@ -47,8 +47,8 @@ object ImageUtil {
fun findImageType(stream: InputStream): ImageType? { fun findImageType(stream: InputStream): ImageType? {
return try { return try {
val decoder = ImageDecoder.new(stream) val format = ImageDecoder.new(stream).use { dec -> dec.format }
when (decoder.format) { when (format) {
"jpeg" -> ImageType.JPEG "jpeg" -> ImageType.JPEG
"png" -> ImageType.PNG "png" -> ImageType.PNG
"webp" -> ImageType.WEBP "webp" -> ImageType.WEBP
@@ -76,8 +76,7 @@ object ImageUtil {
ImageType.GIF -> true ImageType.GIF -> true
ImageType.WEBP, ImageType.HEIF -> { ImageType.WEBP, ImageType.HEIF -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
val decoder = ImageDecoder.new(source.peek().inputStream()) ImageDecoder.new(source.peek().inputStream()).use { dec -> dec.pages > 1 }
decoder.pages > 1
} }
else -> false else -> false
@@ -303,19 +302,17 @@ object ImageUtil {
* Algorithm for determining what background to accompany a comic/manga page * Algorithm for determining what background to accompany a comic/manga page
*/ */
fun chooseBackground(context: Context, imageStream: InputStream): Drawable { fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val decoder = try { val image = try {
ImageDecoder.new(imageStream) 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) { } catch (e: Exception) {
logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" } logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" }
null 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 val whiteColor = Color.WHITE
if (image == null) return whiteColor.toDrawable() if (image == null) return whiteColor.toDrawable()
+2 -4
View File
@@ -39,11 +39,10 @@ firebase-bom = "34.19.0"
firebase-crashlytics = "3.0.8" firebase-crashlytics = "3.0.8"
flexibleAdapter = "5.1.0" flexibleAdapter = "5.1.0"
google-services = "4.5.0" google-services = "4.5.0"
image-decoder = "13" image-decoder = "14"
injekt = "1.16.1" injekt = "1.16.1"
jsoup = "1.23.2" jsoup = "1.23.2"
junit = "6.1.3" junit = "6.1.3"
kim = "0.40.0"
kotest-assertions = "6.2.5" kotest-assertions = "6.2.5"
kotlin-gradle = "2.4.20" kotlin-gradle = "2.4.20"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
@@ -75,7 +74,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 = "41" webgpuviewer = "46"
xmlutil = "1.0.2" xmlutil = "1.0.2"
[libraries] [libraries]
@@ -142,7 +141,6 @@ injekt = { module = "uy.kohesive.injekt:injekt-core", version.ref = "injekt" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } 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" } 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-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" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle" }