diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 796953e74..734f415c7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -298,7 +298,6 @@ dependencies { implementation(libs.image.decoder) implementation(libs.webgpuviewer) - implementation(libs.kim) // UI libraries implementation(libs.material) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt index 0e2f0dd63..24dc17f6d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt @@ -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,19 +35,10 @@ 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt index e84dcc664..2243bc280 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt @@ -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,12 +518,47 @@ 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 - invalidate() + 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 set(value) { field = value @@ -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,121 +1144,130 @@ 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 - if (pageCount == 0) throw Exception("No frames decoded") + if (pageCount == 0) throw Exception("No frames decoded") - val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor() + val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor() - val firstFrame = dec.decodeNext() + val firstFrame = dec.decodeNext() - val imagePage = if (pageCount == 1) { - // Only trim when not animated and not in dual page mode - val trimColors = if (config.imageCropBorders && !isDualPageMode()) { - listOf( - floatArrayOf(1f, 1f, 1f), - floatArrayOf(0f, 0f, 0f), + val imagePage = if (pageCount == 1) { + // Only trim when not animated and not in dual page mode + val trimColors = if (config.imageCropBorders && !isDualPageMode()) { + listOf( + floatArrayOf(1f, 1f, 1f), + floatArrayOf(0f, 0f, 0f), + ) + } else { + null + } + + val firstImage = Image( + firstFrame.image, + firstFrame.width, + firstFrame.height, + createMipMaps = true, + trimColors = trimColors, + trimThreshold = 0.15f, + backgroundColor = backgroundColor, + hdr = firstFrame.isHdr, + hdrHeadroom = firstFrame.hdrHeadroom, + gainmap = firstFrame.gainmapInput(), ) + + ImagePage.ImageSingle(firstImage) } else { - null + val frames = ArrayList>(pageCount) + + // Built frames hold uploaded textures, and ImageSingle owns the only teardown. + fun discardFrames() { + if (frames.isNotEmpty()) ImagePage.ImageSingle(frames).cleanup() + } + + val firstImage = Image( + firstFrame.image, + firstFrame.width, + firstFrame.height, + createMipMaps = false, + backgroundColor = backgroundColor, + hdr = firstFrame.isHdr, + hdrHeadroom = firstFrame.hdrHeadroom, + gainmap = firstFrame.gainmapInput(), + ) + + frames.add(Pair(firstImage, firstFrame.duration)) + + try { + for (i in 1 until 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 image = Image( + frame.image, + frame.width, + frame.height, + createMipMaps = false, + backgroundColor = firstImage.backgroundColor, + hdr = frame.isHdr, + hdrHeadroom = frame.hdrHeadroom, + gainmap = frame.gainmapInput(), + ) + frames.add(Pair(image, frame.duration)) + } + } catch (e: Throwable) { + discardFrames() + throw e + } + + ImagePage.ImageSingle(frames) } - val firstImage = Image( - firstFrame.image, - firstFrame.width, - firstFrame.height, - createMipMaps = true, - trimColors = trimColors, - trimThreshold = 0.15f, - backgroundColor = backgroundColor, - hdr = firstFrame.isHdr, - hdrHeadroom = firstFrame.hdrHeadroom, - gainmap = firstFrame.gainmapInput(), - ) - - ImagePage.ImageSingle(firstImage) - } else { - val frames = ArrayList>(pageCount) - - // Built frames hold uploaded textures, and ImageSingle owns the only teardown. - fun discardFrames() { - if (frames.isNotEmpty()) ImagePage.ImageSingle(frames).cleanup() - } - - val firstImage = Image( - firstFrame.image, - firstFrame.width, - firstFrame.height, - createMipMaps = false, - backgroundColor = backgroundColor, - hdr = firstFrame.isHdr, - hdrHeadroom = firstFrame.hdrHeadroom, - gainmap = firstFrame.gainmapInput(), - ) - - frames.add(Pair(firstImage, firstFrame.duration)) - - try { - for (i in 1 until 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 + synchronized(lock) { + if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { + val oldImagePage = page.imagePage + page.imagePage = imagePage + noteIfLone(page) + page.state = PageState.IDLE + cleanupImage(oldImagePage) + // 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. + if (oldImagePage.isOnScreen) imagePage.fadeIn() + if (!isDualPageMode()) { + (page.imagePage as? ImagePage.ImageSingle)?.let { + if (!applyWideZoomIfNeeded(it)) { + applyFitModeAnchor(it) } } } - - // Scrolled past: the frames left are work nothing will draw. - if (!stillWanted) { - discardFrames() - return - } - - val frame = dec.decodeNext() - val image = Image( - frame.image, - frame.width, - frame.height, - createMipMaps = false, - backgroundColor = firstImage.backgroundColor, - hdr = frame.isHdr, - hdrHeadroom = frame.hdrHeadroom, - gainmap = frame.gainmapInput(), - ) - frames.add(Pair(image, frame.duration)) + pager.state.invalidate() + } else { + if (pageInCache(page)) page.state = PageState.IDLE + imagePage.cleanup() } - } catch (e: Throwable) { - discardFrames() - throw e - } - - ImagePage.ImageSingle(frames) - } - - synchronized(lock) { - if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { - val oldImagePage = page.imagePage - page.imagePage = imagePage - noteIfLone(page) - page.state = PageState.IDLE - cleanupImage(oldImagePage) - // 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. - if (oldImagePage.isOnScreen) imagePage.fadeIn() - if (!isDualPageMode()) { - (page.imagePage as? ImagePage.ImageSingle)?.let { - if (!applyWideZoomIfNeeded(it)) { - applyFitModeAnchor(it) - } - } - } - pager.state.invalidate() - } else { - if (pageInCache(page)) page.state = PageState.IDLE - imagePage.cleanup() } } } @@ -1405,13 +1432,17 @@ open class WebGpuViewer( // MainScope, not the state's: that one dispatches inside the frame callback. val settled = page this@WebGpuViewer.scope.launch { - activity.hideMenu() - progressPage(settled)?.let { activity.onPageSelected(it.page) } + if (!isContinuous) { + activity.hideMenu() + progressPage(settled)?.let { activity.onPageSelected(it.page) } + } preloadPages(settled) - (settled as? ViewerTransitionPage)?.let { transitionPage -> - if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { - activity.showMenu() + if (!isContinuous) { + (settled as? ViewerTransitionPage)?.let { transitionPage -> + if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + activity.showMenu() + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt index 8af89fb80..5436ecf5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt @@ -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,26 +15,71 @@ 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) { state.animateScroll(direction * state.height / 2f) } @@ -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() } diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt index 97885e05a..03e40973e 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt @@ -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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ffd146c71..d881cfb91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }