mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Fix webtoon scroll stutter between pages
Closes #4026 Co-authored-by: Matheus William <matheuswr89@outlook.com>
This commit is contained in:
co-authored by
Matheus William
parent
039138ce99
commit
b5856135c5
@@ -154,6 +154,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
- Fixed actions started from a dialog sometimes not running when the dialog closed right away, Kitsu, Shikimori, MangaBaka, Bangumi and Hikka sometimes refreshing their login twice at once, adding an extension repo served over HTTP failing, and cancelling an extension install not stopping its download ([@AntsyLich](https://github.com/AntsyLich)) ([#4068](https://github.com/mihonapp/mihon/pull/4068))
|
||||
- Fixed the next library update staying "Soon" after new chapters were found, and chapters that came out early, late or were dated earlier by the source pushing the next check back ([@AntsyLich](https://github.com/AntsyLich)) ([`ba44815`](https://github.com/mihonapp/mihon/commit/ba44815))
|
||||
- Fixed long strip side padding being counted in zoom bounds ([@WizaHX](https://github.com/WizaHX)) ([#4072](https://github.com/mihonapp/mihon/pull/4072))
|
||||
- Fixed long strip stuttering between pages and using a lot of memory with very tall pages ([@AntsyLich](https://github.com/AntsyLich), [@matheuswr89](https://github.com/matheuswr89))
|
||||
|
||||
### Other
|
||||
- Show "Nightly" instead of "Beta" for preview builds and "FOSS" for FOSS builds in About ([@AntsyLich](https://github.com/AntsyLich)) ([#3760](https://github.com/mihonapp/mihon/pull/3760))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import ca.mpreg.imagedecoder.ImageDecoder
|
||||
@@ -14,8 +15,12 @@ import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
|
||||
@@ -43,7 +48,22 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
override fun draw(canvas: Canvas) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
|
||||
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
|
||||
* the whole page as one texture.
|
||||
*/
|
||||
class StripsImage(val strips: List<Bitmap>) : Image {
|
||||
override val size: Long = strips.sumOf { it.allocationByteCount.toLong() }
|
||||
override val width: Int = strips.firstOrNull()?.width ?: 0
|
||||
override val height: Int = strips.sumOf { it.height }
|
||||
override val shareable: Boolean get() = false
|
||||
override fun draw(canvas: Canvas) {}
|
||||
}
|
||||
|
||||
override suspend fun decode(): DecodeResult {
|
||||
if (options.webtoonStrips) return decodeStrips()
|
||||
|
||||
val res = resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec ->
|
||||
DecodeResultImage(
|
||||
@@ -105,9 +125,67 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies bands of rows straight from the native frame into strips, so the page never exists as
|
||||
* one Java bitmap on top of the frame.
|
||||
*
|
||||
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
|
||||
* in still shows the page's detail, unless that would make the page take more than
|
||||
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
|
||||
*/
|
||||
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
|
||||
val (frame, isHdr) = resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr }
|
||||
}
|
||||
frame.use {
|
||||
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
||||
val bytesPerPixel = if (isHdr) 8 else 4
|
||||
val rowBytes = frame.width * bytesPerPixel
|
||||
val viewWidth = options.size.widthPx(options.scale) { frame.width }
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = frame.width,
|
||||
srcHeight = frame.height,
|
||||
dstWidth = viewWidth,
|
||||
dstHeight = options.size.heightPx(options.scale) { frame.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt()
|
||||
val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth))
|
||||
// Older GPUs can't draw a texture wider than this
|
||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||
.coerceIn(1, frame.width)
|
||||
val scale = stripWidth.toFloat() / frame.width
|
||||
|
||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
|
||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||
val band = createBitmap(frame.width, bandRows, config)
|
||||
val strips = (0..<frame.height step bandRows).map { top ->
|
||||
val rows = min(bandRows, frame.height - top)
|
||||
if (rows != band.height) band.reconfigure(frame.width, rows, config)
|
||||
band.copyPixelsFromBuffer(
|
||||
frame.image.duplicate().apply {
|
||||
position(top * rowBytes)
|
||||
limit((top + rows) * rowBytes)
|
||||
},
|
||||
)
|
||||
val strip = if (stripWidth < frame.width) {
|
||||
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
|
||||
} else {
|
||||
band
|
||||
}
|
||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
|
||||
}
|
||||
band.recycle()
|
||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
||||
return if (options.newDecoder || options.customDecoder || isApplicable(result.source.source())) {
|
||||
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
|
||||
ImageDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
@@ -134,3 +212,12 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
private const val STRIP_BYTES = 4 * 1024 * 1024
|
||||
|
||||
private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private const val MAX_STRIP_WIDTH = 4096
|
||||
|
||||
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
|
||||
private val stripDecodes = Semaphore(1)
|
||||
|
||||
@@ -34,14 +34,14 @@ val Options.cropBorders: Boolean
|
||||
|
||||
private val cropBordersKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply {
|
||||
extras[customDecoderKey] = enable
|
||||
fun ImageRequest.Builder.webtoonStrips(enable: Boolean) = apply {
|
||||
extras[webtoonStripsKey] = enable
|
||||
}
|
||||
|
||||
val Options.customDecoder: Boolean
|
||||
get() = getExtra(customDecoderKey)
|
||||
val Options.webtoonStrips: Boolean
|
||||
get() = getExtra(webtoonStripsKey)
|
||||
|
||||
private val customDecoderKey = Extras.Key(default = false)
|
||||
private val webtoonStripsKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
|
||||
extras[newDecoderKey] = enable
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.CallSuper
|
||||
@@ -18,11 +19,11 @@ import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.os.postDelayed
|
||||
import androidx.core.view.isVisible
|
||||
import coil3.BitmapImage
|
||||
import coil3.asDrawable
|
||||
import coil3.dispose
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Precision
|
||||
@@ -33,9 +34,10 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_IN_OUT
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_OUT_QUAD
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import eu.kanade.tachiyomi.data.coil.cropBorders
|
||||
import eu.kanade.tachiyomi.data.coil.customDecoder
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
|
||||
import eu.kanade.tachiyomi.data.coil.webtoonStrips
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||
import okio.BufferedSource
|
||||
@@ -45,7 +47,7 @@ import okio.BufferedSource
|
||||
*
|
||||
* Animated image will be drawn by [PhotoView] while [SubsamplingScaleImageView] will take non-animated image.
|
||||
*
|
||||
* @param isWebtoon if true, [WebtoonSubsamplingImageView] will be used instead of [SubsamplingScaleImageView]
|
||||
* @param isWebtoon if true, [WebtoonStripImageView] will be used instead of [SubsamplingScaleImageView]
|
||||
* and [AppCompatImageView] will be used instead of [PhotoView]
|
||||
*/
|
||||
open class ReaderPageImageView @JvmOverloads constructor(
|
||||
@@ -58,6 +60,8 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
|
||||
private var pageView: View? = null
|
||||
|
||||
private var stripRequest: Disposable? = null
|
||||
|
||||
private var config: Config? = null
|
||||
|
||||
var onImageLoaded: (() -> Unit)? = null
|
||||
@@ -155,18 +159,26 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
if (isAnimated) {
|
||||
prepareAnimatedImageView()
|
||||
setAnimatedImage(source, config)
|
||||
} else if (isWebtoon) {
|
||||
prepareStripImageView()
|
||||
setStripImage(source, config)
|
||||
} else {
|
||||
prepareNonAnimatedImageView()
|
||||
setNonAnimatedImage(source, config)
|
||||
}
|
||||
}
|
||||
|
||||
fun recycle() = pageView?.let {
|
||||
when (it) {
|
||||
is SubsamplingScaleImageView -> it.recycle()
|
||||
is AppCompatImageView -> it.dispose()
|
||||
fun recycle() {
|
||||
stripRequest?.dispose()
|
||||
stripRequest = null
|
||||
pageView?.let {
|
||||
when (it) {
|
||||
is SubsamplingScaleImageView -> it.recycle()
|
||||
is AppCompatImageView -> it.dispose()
|
||||
is WebtoonStripImageView -> it.recycle()
|
||||
}
|
||||
it.isVisible = false
|
||||
}
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,11 +239,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
if (pageView is SubsamplingScaleImageView) return
|
||||
removeView(pageView)
|
||||
|
||||
pageView = if (isWebtoon) {
|
||||
WebtoonSubsamplingImageView(context)
|
||||
} else {
|
||||
SubsamplingScaleImageView(context)
|
||||
}.apply {
|
||||
pageView = SubsamplingScaleImageView(context).apply {
|
||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
|
||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||
setMinimumTileDpi(180)
|
||||
@@ -292,35 +300,8 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
isVisible = true
|
||||
}
|
||||
is BufferedSource -> {
|
||||
if (!isWebtoon) {
|
||||
setImage(ImageSource.inputStream(data.inputStream()))
|
||||
isVisible = true
|
||||
return@apply
|
||||
}
|
||||
|
||||
ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.target(
|
||||
onSuccess = { result ->
|
||||
val image = result as BitmapImage
|
||||
setImage(ImageSource.bitmap(image.bitmap))
|
||||
isVisible = true
|
||||
},
|
||||
)
|
||||
.listener(
|
||||
onError = { _, result ->
|
||||
onImageLoadError(result.throwable)
|
||||
},
|
||||
)
|
||||
.size(ViewSizeResolver(this@ReaderPageImageView))
|
||||
.precision(Precision.INEXACT)
|
||||
.cropBorders(config.cropBorders)
|
||||
.customDecoder(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
.let(context.imageLoader::enqueue)
|
||||
setImage(ImageSource.inputStream(data.inputStream()))
|
||||
isVisible = true
|
||||
}
|
||||
else -> {
|
||||
throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
|
||||
@@ -328,6 +309,51 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareStripImageView() {
|
||||
if (pageView is WebtoonStripImageView) return
|
||||
removeView(pageView)
|
||||
|
||||
pageView = WebtoonStripImageView(context)
|
||||
addView(pageView, MATCH_PARENT, WRAP_CONTENT)
|
||||
}
|
||||
|
||||
private fun setStripImage(data: BufferedSource, config: Config) {
|
||||
val view = pageView as? WebtoonStripImageView ?: return
|
||||
view.onReload = { requestStrips(view, data, config, isReload = true) }
|
||||
requestStrips(view, data, config, isReload = false)
|
||||
}
|
||||
|
||||
private fun requestStrips(view: WebtoonStripImageView, data: BufferedSource, config: Config, isReload: Boolean) {
|
||||
// A recycled view is reused for another page, where a request still running for the old one must not land
|
||||
stripRequest?.dispose()
|
||||
stripRequest = ImageRequest.Builder(context)
|
||||
// Peeking leaves the data whole for decoding again after the strips were freed off screen
|
||||
.data(data.peek())
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.target(
|
||||
onSuccess = { result ->
|
||||
view.setImage(result as ImageDecoder.StripsImage)
|
||||
if (!isReload) {
|
||||
view.isVisible = true
|
||||
onImageLoaded()
|
||||
}
|
||||
},
|
||||
)
|
||||
.listener(
|
||||
onError = { _, result ->
|
||||
onImageLoadError(result.throwable)
|
||||
},
|
||||
)
|
||||
.size(ViewSizeResolver(this))
|
||||
.precision(Precision.INEXACT)
|
||||
.cropBorders(config.cropBorders)
|
||||
.webtoonStrips(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
.let(context.imageLoader::enqueue)
|
||||
}
|
||||
|
||||
private fun prepareAnimatedImageView() {
|
||||
if (pageView is AppCompatImageView) return
|
||||
removeView(pageView)
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Draws a webtoon page as a stack of strips, so a very tall page never becomes one huge GPU texture.
|
||||
*
|
||||
* Ignores all touch events, because the webtoon viewer handles all the gestures.
|
||||
*/
|
||||
class WebtoonStripImageView(context: Context) : View(context) {
|
||||
|
||||
private var strips: List<Bitmap> = emptyList()
|
||||
private var imageWidth = 0
|
||||
private var imageHeight = 0
|
||||
|
||||
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
private val clipRect = Rect()
|
||||
private val dstRect = Rect()
|
||||
|
||||
/** Decodes the page again after its strips were freed off screen. */
|
||||
var onReload: (() -> Unit)? = null
|
||||
|
||||
private var isReleasedOffScreen = false
|
||||
|
||||
fun setImage(image: ImageDecoder.StripsImage) {
|
||||
releaseStrips()
|
||||
isReleasedOffScreen = false
|
||||
strips = image.strips
|
||||
imageWidth = image.width
|
||||
imageHeight = image.height
|
||||
requestLayout()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
releaseStrips()
|
||||
isReleasedOffScreen = false
|
||||
onReload = null
|
||||
imageWidth = 0
|
||||
imageHeight = 0
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
// The viewer keeps a few pages off screen without recycling them, which adds up for huge pages.
|
||||
// The size stays, so the layout doesn't move while the page decodes again.
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
if (strips.sumOf { it.allocationByteCount.toLong() } > LARGE_PAGE_BYTES) {
|
||||
releaseStrips()
|
||||
isReleasedOffScreen = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (isReleasedOffScreen) {
|
||||
isReleasedOffScreen = false
|
||||
onReload?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
// A page's strips can be hundreds of MB, which would otherwise wait for a garbage collection to be freed
|
||||
private fun releaseStrips() {
|
||||
val released = strips
|
||||
strips = emptyList()
|
||||
released.forEach(Bitmap::recycle)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = if (imageWidth > 0) (width.toLong() * imageHeight / imageWidth).toInt() else 0
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (imageWidth <= 0) return
|
||||
canvas.getClipBounds(clipRect)
|
||||
|
||||
val scale = width.toFloat() / imageWidth
|
||||
var srcTop = 0
|
||||
for (strip in strips) {
|
||||
val top = (srcTop * scale).roundToInt()
|
||||
srcTop += strip.height
|
||||
val bottom = (srcTop * scale).roundToInt()
|
||||
if (bottom <= clipRect.top) continue
|
||||
if (top >= clipRect.bottom) break
|
||||
dstRect.set(0, top, width, bottom)
|
||||
canvas.drawBitmap(strip, null, dstRect, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean = false
|
||||
}
|
||||
|
||||
private const val LARGE_PAGE_BYTES = 64L * 1024 * 1024
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
|
||||
/**
|
||||
* Implementation of subsampling scale image view that ignores all touch events, because the
|
||||
* webtoon viewer handles all the gestures.
|
||||
*/
|
||||
class WebtoonSubsamplingImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : SubsamplingScaleImageView(context, attrs) {
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user