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:
@@ -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()
|
||||
}
|
||||
|
||||
+60
-13
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user