diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 5c639840d..9906db3e7 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.graphics.drawable.toDrawable import androidx.core.view.updatePadding +import ca.mpreg.webgpuviewer.renderer.Image +import ca.mpreg.webgpuviewer.renderer.Image.Companion.invoke import ca.mpreg.webgpuviewer.renderer.WebGpuRenderer import ca.mpreg.webgpuviewer.viewer.ImagePage import ca.mpreg.webgpuviewer.viewer.ImageViewer @@ -56,6 +58,7 @@ import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.tachiyomi.data.coil.ImageDecoder import eu.kanade.tachiyomi.data.coil.newDecoder import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import mihon.app.di.appGraph import tachiyomi.domain.manga.model.Manga @@ -164,8 +167,6 @@ fun MangaCoverDialog( if (useNewRenderer) { val state = ImageViewerState() - state.dpi = view.resources.displayMetrics.densityDpi / 100f - ImageRequest.Builder(view.context) .data(manga) .size(Size.ORIGINAL) @@ -173,18 +174,20 @@ fun MangaCoverDialog( .newDecoder(true) .target { result -> val res = (result as ImageDecoder.DecodeResultImage).res - val page = runBlocking(WebGpuRenderer.dispatcher) { - ImagePage(res.image, res.width, res.height) - }.apply { - image?.backgroundColor = 0 + val page = runBlocking(Dispatchers.Default) { + ImagePage.ImageSingle( + Image( + res.image, + res.width, + res.height, + createMipMaps = true, + backgroundColor = 0, + ), + ) } state.apply { fetchPage = { index -> - if (index == 0) { - page - } else { - null - } + if (index == 0) page else null } invalidate() } 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 53a428d63..14a1641c9 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 @@ -1,21 +1,20 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webgpu -import android.graphics.Canvas import android.graphics.Color -import android.graphics.Paint import android.graphics.PointF import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import androidx.core.graphics.createBitmap +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.webgpu.GPUTexture import ca.mpreg.imagedecoder.ImageDecoder import ca.mpreg.webgpuviewer.ImageView -import ca.mpreg.webgpuviewer.draw.Draw -import ca.mpreg.webgpuviewer.draw.clear -import ca.mpreg.webgpuviewer.draw.line +import ca.mpreg.webgpuviewer.closeTo +import ca.mpreg.webgpuviewer.draw.TextAlign import ca.mpreg.webgpuviewer.renderer.Image -import ca.mpreg.webgpuviewer.renderer.WebGpuRenderer import ca.mpreg.webgpuviewer.transition.TransitionBasic import ca.mpreg.webgpuviewer.transition.TransitionCube import ca.mpreg.webgpuviewer.transition.TransitionCubeOuter @@ -95,7 +94,13 @@ open class WebGpuViewer( private val pageCache = LinkedHashMap() // Decode queue - pages waiting to be decoded, processed LIFO (last = highest priority) - private val decodeQueue = ArrayDeque() + private val decodeQueue = ArrayDeque() + + /** + * Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping + * for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself. + */ + internal enum class SpreadPosition { LEFT, RIGHT, SINGLE } // Stable key types for page identity - data classes provide correct equals/hashCode private sealed class PageKey { @@ -105,7 +110,7 @@ open class WebGpuViewer( private fun pageKey(page: ViewerPage): PageKey = when (page) { is ViewerReaderPage -> PageKey.Reader(page.page.chapter.chapter.id, page.page.index) - is TransitionPage -> PageKey.Transition(page.prevChapter?.chapter?.id, page.nextChapter?.chapter?.id) + is ViewerTransitionPage -> PageKey.Transition(page.prevChapter?.chapter?.id, page.nextChapter?.chapter?.id) else -> PageKey.Transition(null, null) } @@ -119,9 +124,9 @@ open class WebGpuViewer( * If prioritize=true and page is already queued, moves it to front. * Must be called while holding lock. */ - private fun queueForDecode(page: ViewerPage, prioritize: Boolean = false) { + private fun queueForDecode(page: ViewerReaderPage, prioritize: Boolean = false) { // Already has a decoded image - if (page.imagePage.isDecoded) return + if (page.isDecoded) return when (page.state) { PageState.IDLE -> { @@ -156,12 +161,12 @@ open class WebGpuViewer( while (decodeQueue.isEmpty()) { lock.wait() } - decodeQueue.removeLast().also { it.state = PageState.DECODING } + decodeQueue.removeLast().apply { state = PageState.DECODING } } // Verify page is still valid (not evicted and doesn't have a decoded image yet) val shouldProcess = synchronized(lock) { - pageInCache(page) && page.state == PageState.DECODING && !page.imagePage.isDecoded + pageInCache(page) && page.state == PageState.DECODING && !page.isDecoded } if (!shouldProcess) { @@ -172,13 +177,21 @@ open class WebGpuViewer( } try { - when (page) { - is ViewerReaderPage -> decodeReaderPage(page) - is TransitionPage -> createTransitionPage(page) - } + decodeReaderPage(page) } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Decode error: ${pageKey(page)}" } - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } + logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" } + synchronized(lock) { + if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { + val oldImagePage = page.imagePage + val errorMessage = e.message ?: "Failed to decode image" + page.imagePage = ErrorPage(errorMessage, page.spreadPosition) + page.state = PageState.IDLE + oldImagePage.cleanup() + page.imagePage.invalidate() + } else { + if (pageInCache(page)) page.state = PageState.IDLE + } + } } } } catch (_: InterruptedException) { @@ -231,7 +244,7 @@ open class WebGpuViewer( val nextIndex = page.page.index + 1 candidates.find { it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == nextIndex - } ?: candidates.find { it is TransitionPage && it.prevChapter?.chapter?.id == chapterId } + } ?: candidates.find { it is ViewerTransitionPage && it.prevChapter?.chapter?.id == chapterId } ?: page.nextChapter?.chapter?.id?.let { nextChapterId -> candidates.find { it is ViewerReaderPage && it.page.chapter.chapter.id == nextChapterId && it.page.index == 0 @@ -239,7 +252,7 @@ open class WebGpuViewer( } } - is TransitionPage -> { + is ViewerTransitionPage -> { val nextChapterId = page.nextChapter?.chapter?.id candidates.find { it is ViewerReaderPage && it.page.chapter.chapter.id == nextChapterId && it.page.index == 0 @@ -255,7 +268,7 @@ open class WebGpuViewer( val prevIndex = page.page.index - 1 candidates.find { it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == prevIndex - } ?: candidates.find { it is TransitionPage && it.nextChapter?.chapter?.id == chapterId } + } ?: candidates.find { it is ViewerTransitionPage && it.nextChapter?.chapter?.id == chapterId } ?: page.prevChapter?.let { prevChapter -> prevChapter.pages?.lastIndex?.let { lastIndex -> candidates.find { @@ -266,7 +279,7 @@ open class WebGpuViewer( } } - is TransitionPage -> { + is ViewerTransitionPage -> { val prevChapterId = page.prevChapter?.chapter?.id page.prevChapter?.pages?.lastIndex?.let { lastIndex -> candidates.find { @@ -324,7 +337,7 @@ open class WebGpuViewer( ): ViewerPage { val key = PageKey.Transition(prevChapter?.chapter?.id, nextChapter?.chapter?.id) return synchronized(lock) { - findInCache(key) ?: TransitionPage(prevChapter, nextChapter).also { newPage -> + findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage -> pageCache[key] = newPage while (pageCache.size > cacheSize) { evictFarthestPage(referencePage ?: newPage) @@ -353,6 +366,114 @@ open class WebGpuViewer( } } + inner class ErrorPage internal constructor( + var message: String, + spreadPosition: SpreadPosition = SpreadPosition.SINGLE, + ) : ImagePage.Render( + if (spreadPosition == SpreadPosition.SINGLE) pager.state.width else pager.state.width / 2, + pager.state.height, + ) { + init { + minScale = 1f + maxScale = 1f + homeScale = 1f + } + + override val backgroundColor: Int = readerBackgroundColor() + + override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { + val padding = with(pager.state.density) { 24.dp.toPx() } + val size = scale * with(pager.state.density) { 16.dp.toPx() } + + val cx = dst.width * (0.5f + scale * x) + val cy = dst.height * (0.5f + scale * y) + + text( + dst, + activity.baseContext, + FontFamily.Default, + message, + cx, + cy, + size, + color = readerOnBackgroundColor(), + align = TextAlign.Center, + maxWidth = dst.width - 2f * padding, + ) + } + } + + inner class ProgressPage(var foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render( + if (!isDualPageMode()) pager.state.width else pager.state.width / 2, + pager.state.height, + ) { + var progress: Float = 0f + + init { + minScale = 1f + maxScale = 1f + homeScale = 1f + } + + override val backgroundColor: Int = readerBackgroundColor() + + override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { + 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 + + circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt()) + + val diameter = full * progress.fastCoerceIn(0f, 1f) + if (diameter > 0) { + circle(cx, cy, diameter / 2f, foregroundColor) + } + } + } + + inner class TransitionPage(val prevChapter: ReaderChapter?, val nextChapter: ReaderChapter?) : ImagePage.Render( + min(pager.state.width, pager.state.height), + min(pager.state.width, pager.state.height), + ) { + init { + minScale = 1f + maxScale = 1f + homeScale = 1f + } + + override val backgroundColor: Int = readerBackgroundColor() + + override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { + val lines: MutableList = mutableListOf() + prevChapter?.chapter?.let { chapter -> lines.add("Previous: " + chapter.name) } + nextChapter?.chapter?.let { chapter -> lines.add("Next: " + chapter.name) } + + val text = lines.joinToString("\n") + + val padding = with(pager.state.density) { 24.dp.toPx() } + val size = scale * with(pager.state.density) { 16.dp.toPx() } + + val cx = dst.width * (0.5f + scale * x) + val cy = dst.height * (0.5f + scale * y) + + text( + dst, + activity.baseContext, + FontFamily.Default, + text, + cx, + cy, + size, + readerOnBackgroundColor(), + align = TextAlign.Center, + maxWidth = dst.width - 2f * padding, + ) + } + } + abstract class ViewerPage { abstract val prevChapter: ReaderChapter? abstract val nextChapter: ReaderChapter? @@ -364,10 +485,16 @@ open class WebGpuViewer( @Volatile open var imagePage: ImagePage = ImagePage.Dummy(400, 400) + + open val isDecoded = true } - inner class TransitionPage(override val prevChapter: ReaderChapter?, override val nextChapter: ReaderChapter?) : - ViewerPage() { + inner class ViewerTransitionPage( + override val prevChapter: ReaderChapter?, + override val nextChapter: ReaderChapter?, + ) : ViewerPage() { + override var imagePage: ImagePage = TransitionPage(prevChapter, nextChapter) + override val prev: ViewerPage? get() = prevChapter?.pages?.lastOrNull()?.let { getPage(it, currentPage) } @@ -377,7 +504,15 @@ open class WebGpuViewer( inner class ViewerReaderPage(val page: ReaderPage) : ViewerPage() { /** Cached spread ImagePage when this page is the anchor of a dual-page spread */ - var spreadPage: ImagePage? = null + var spreadPage: ImagePage.ImageSpread? = null + + /** Which side of a dual-page spread this page belongs on - set once decoding tags it. */ + internal var spreadPosition: SpreadPosition = SpreadPosition.SINGLE + + override var imagePage: ImagePage = ProgressPage() + + override val isDecoded + get() = (imagePage as? ImagePage.ImageSingle)?.isDecoded == true override val prevChapter: ReaderChapter? get() = when (page.chapter) { @@ -447,18 +582,18 @@ open class WebGpuViewer( /** * Check if the given page can form a spread with the next page. - * Uses image.position to determine: anchor + partner = spread + * Uses page.spreadPosition to determine: anchor + partner = spread * RTL: RIGHT is anchor, looks for LEFT on next * LTR: LEFT is anchor, looks for RIGHT on next */ private fun canFormSpread(page: ViewerReaderPage): Boolean { if (!isDualPageMode()) return false - val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT - val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT - if (page.imagePage.image?.position != anchorPosition) return false + val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT + val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT + if (page.spreadPosition != anchorPosition) return false val next = page.next as? ViewerReaderPage ?: return false if (next.page.chapter != page.page.chapter) return false - return next.imagePage.image?.position == partnerPosition + return next.spreadPosition == partnerPosition } /** @@ -470,13 +605,13 @@ open class WebGpuViewer( if (!isDualPageMode()) return page if (page !is ViewerReaderPage) return page - val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT - val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT + val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT + val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT // If this is a partner page, check if previous is anchor - if (page.imagePage.image?.position == partnerPosition) { + if (page.spreadPosition == partnerPosition) { val prev = page.prev as? ViewerReaderPage ?: return page - if (prev.page.chapter == page.page.chapter && prev.imagePage.image?.position == anchorPosition) { + if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition) { return prev } } @@ -485,52 +620,61 @@ open class WebGpuViewer( return page } - /** - * Build an ImagePage for the given page, potentially combining with adjacent page for spread. - * RTL: RIGHT anchor + LEFT partner - * LTR: LEFT anchor + RIGHT partner - */ private fun buildSpreadPage(page: ViewerPage): ImagePage { - // For TransitionPage, return its imagePage directly + // For ViewerTransitionPage, return its imagePage directly if (page !is ViewerReaderPage) { return page.imagePage } - val image = page.imagePage.image - // Only form spreads in dual page mode if (!isDualPageMode()) { return page.imagePage } - val anchorPosition = if (isReversed) Image.Position.RIGHT else Image.Position.LEFT - val partnerPosition = if (isReversed) Image.Position.LEFT else Image.Position.RIGHT + // Whatever the page is holding takes its half of the seam, decoded or not: + // [ImagePage.ImageSpread] draws a [ImagePage.Render] side into its own half. A page left + // out would take the whole viewport instead, hiding its partner with it. + val imagePage = page.imagePage - // Anchor pages look for partner on next page - if (image?.position == anchorPosition) { - val nextReaderPage = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } - val partnerImage = nextReaderPage?.imagePage?.image?.takeIf { it.position == partnerPosition } - - if (partnerImage != null) { - // Reuse existing spread if images match - preserves transform state - val existing = page.spreadPage - if (existing != null && existing.images.getOrNull(0) === image && - existing.images.getOrNull(1) === partnerImage - ) { - return existing - } - - // Create new spread: [anchor, partner] - val spread = ImagePage(image, partnerImage) - spread.ownsImages = false - page.spreadPage = spread - return spread - } + if (page.spreadPosition == SpreadPosition.SINGLE) { + page.spreadPage = null + return imagePage } - // Single page or no spread partner - clear any cached spread - page.spreadPage = null - return page.imagePage + val anchorPosition = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT + val partnerPosition = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT + + // Only the anchor side looks for a partner on the next page. A partner-tagged page only + // reaches this function directly (rather than being redirected here via + // [getSpreadAnchor]) when it has no anchor before it - a lone RIGHT with no preceding + // LEFT (or vice versa), e.g. at a chapter boundary - so it renders alone on its own side + // instead of looking anywhere else for a partner. + val partnerImagePage = if (page.spreadPosition == anchorPosition) { + val nextReaderPage = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } + nextReaderPage?.imagePage?.takeIf { + nextReaderPage.spreadPosition == partnerPosition + } + } else { + null + } + + // LEFT/RIGHT map directly to the spread's left/right slot - independent of reading + // direction, which only decides which side is the anchor for pairing purposes above. + val left = if (page.spreadPosition == SpreadPosition.LEFT) imagePage else partnerImagePage + val right = if (page.spreadPosition == SpreadPosition.RIGHT) imagePage else partnerImagePage + + // Reuse existing spread if the sides match - preserves transform state + val existing = page.spreadPage + if (existing != null && existing.left === left && existing.right === right) { + return existing + } + + // Create new spread. Composes the existing page(s) directly, so either side (or both) + // keeps animating independently via its own already-running frame loop - no copying of + // animation state needed. The other slot is simply null when there's no partner (yet). + val spread = ImagePage.ImageSpread(left, right) + page.spreadPage = spread + return spread } init { @@ -612,7 +756,7 @@ open class WebGpuViewer( pageCache.clear() currentPage = (currentPage as? ViewerReaderPage)?.page?.let { getPage(it) } - ?: (currentPage as? TransitionPage)?.let { + ?: (currentPage as? ViewerTransitionPage)?.let { getPage(it.prevChapter, it.nextChapter) } @@ -668,7 +812,7 @@ open class WebGpuViewer( // If page is already ready, just re-queue immediately if (page.page.status == Page.State.Ready) { synchronized(lock) { - if (pageInCache(page) && !page.imagePage.isDecoded) { + if (pageInCache(page) && !page.isDecoded) { page.state = PageState.IDLE queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false) } else if (pageInCache(page)) { @@ -701,27 +845,9 @@ open class WebGpuViewer( if (!pageInCache(page) || page.imagePage !is ImagePage.Dummy) return@collect } - if (page.imagePage.image == null) { - page.imagePage = ImagePage.drawable(400, 400).apply { - WebGpuRenderer.withContext { - this@apply.texture?.let { texture -> - Draw.submit { encoder -> - clear(encoder, texture, 0x00000000) - line(encoder, texture, 0.1f, 0.5f, 0.9f, 0.5f, 0xFF101010.toInt(), 30f) - } - } - } - } - } else { - WebGpuRenderer.withContext { - (page.imagePage as ImagePage.Draw?)?.texture?.let { texture -> - Draw.submit { encoder -> - val x2 = 0.1f + (value / 100f) * 0.8f - line(encoder, texture, 0.1f, 0.5f, x2, 0.5f, 0xFFFFFFFF.toInt(), 20f) - } - } - pager.state.invalidate() - } + (page.imagePage as? ProgressPage)?.apply { + progress = value / 100f + invalidate() } } } @@ -744,7 +870,7 @@ open class WebGpuViewer( synchronized(lock) { if (pageInCache(page) && page.state == PageState.LOADING) { page.state = PageState.IDLE - if (page.page.status == Page.State.Ready && !page.imagePage.isDecoded) { + if (page.page.status == Page.State.Ready && !page.isDecoded) { queueForDecode( page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false, @@ -771,103 +897,53 @@ open class WebGpuViewer( return } - var imagePage: ImagePage? = null - try { - stream.use { input -> - // Check if still valid before decoding (not evicted and doesn't have decoded image yet) - synchronized(lock) { - if (!pageInCache(page) || page.imagePage.isDecoded) { - if (pageInCache(page)) page.state = PageState.IDLE - return - } - } - - // Buffer file to detect spread position tag, then decode. - // When not in dual page mode, skip Kim entirely. - val bytes = if (isDualPageMode()) input.readBytes() else null - - val position = if (bytes != null) { - val tag = Kim.readMetadata(bytes.inputStream(), bytes.size.toLong()) - ?.findStringValue(TiffTag.TIFF_TAG_PAGE_NAME) - when (tag) { - "Left" -> Image.Position.LEFT - "Right" -> Image.Position.RIGHT - null -> if (isReversed) { // TODO: heuristics, use image size - if (page.page.index % 2 == 0) Image.Position.LEFT else Image.Position.RIGHT - } else { - if (page.page.index % 2 == 0) Image.Position.RIGHT else Image.Position.LEFT - } - - else -> Image.Position.SINGLE - } - } else { - Image.Position.SINGLE - } - - val dec = try { - ImageDecoder.new(bytes?.inputStream() ?: input) - } catch (e: ImageDecoder.DecodeException) { - logcat(LogPriority.ERROR, e) { "ImageDecoder.new failed: ${e.message}" } - val errorMessage = e.message ?: "Failed to decode image" - val bitmap = createBitmap(pager.state.width.coerceAtLeast(1), pager.state.height.coerceAtLeast(1)) - val canvas = Canvas(bitmap) - canvas.drawColor(readerBackgroundColor()) - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = readerOnBackgroundColor() - textSize = 36f - textAlign = Paint.Align.CENTER - } - val maxWidth = bitmap.width * 0.8f - val words = errorMessage.split(" ") - val lines = mutableListOf() - var currentLine = StringBuilder() - for (word in words) { - val testLine = if (currentLine.isEmpty()) word else "$currentLine $word" - if (paint.measureText(testLine) <= maxWidth) { - currentLine = StringBuilder(testLine) - } else { - if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) - currentLine = StringBuilder(word) - } - } - if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) - val lineHeight = 40f - var y = bitmap.height / 2f - lines.size * lineHeight / 2 - for (line in lines) { - canvas.drawText(line, bitmap.width / 2f, y, paint) - y += lineHeight - } - val errorPage = ImagePage(bitmap, createMipMaps = false).also { - it.image?.position = Image.Position.SINGLE - it.highQuality = false - } - synchronized(lock) { - if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) { - val oldImagePage = page.imagePage - page.imagePage = errorPage - page.state = PageState.IDLE - if (oldImagePage !is ImagePage.Dummy) oldImagePage.cleanup() - pager.state.invalidate() - } else { - if (pageInCache(page)) page.state = PageState.IDLE - errorPage.cleanup() - } - } + stream.use { input -> + // Check if still valid before decoding (not evicted and doesn't have decoded image yet) + synchronized(lock) { + if (!pageInCache(page) || page.isDecoded) { + if (pageInCache(page)) page.state = PageState.IDLE return } - val pageCount = dec.pages + } - if (pageCount == 0) { - logcat(LogPriority.ERROR) { "decodeReaderPage: no frames decoded" } - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } - return + // Buffer file to detect spread position tag, then decode. + // When not in dual page mode, skip Kim entirely. + val bytes = if (isDualPageMode()) input.readBytes() else null + + page.spreadPosition = if (bytes != null) { + val tag = Kim.readMetadata(bytes.inputStream(), bytes.size.toLong()) + ?.findStringValue(TiffTag.TIFF_TAG_PAGE_NAME) + when (tag) { + "Left" -> SpreadPosition.LEFT + "Right" -> SpreadPosition.RIGHT + // Set position for dual page spreads based on reading direction: + // RTL (isReversed): Cover on LEFT, even=LEFT, odd=RIGHT + // LTR (!isReversed): Cover on RIGHT, even=RIGHT, odd=LEFT + null -> if (isReversed) { // TODO: heuristics, use image size + if (page.page.index % 2 == 0) SpreadPosition.LEFT else SpreadPosition.RIGHT + } else { + if (page.page.index % 2 == 0) SpreadPosition.RIGHT else SpreadPosition.LEFT + } + + else -> SpreadPosition.SINGLE } + } else { + SpreadPosition.SINGLE + } - // Decode first frame immediately; defer rest only if animated - val firstFrame = dec.decodeNext() + val dec = ImageDecoder.new(bytes?.inputStream() ?: input) - // For first frame, create Image with trim in single GPU context switch - val trimColors = if (config.imageCropBorders) { + val pageCount = dec.pages + + if (pageCount == 0) throw Exception("No frames decoded") + + val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor() + + 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), @@ -876,12 +952,6 @@ open class WebGpuViewer( null } - val backgroundColor = if (config.automaticBackground) { - null - } else { - readerBackgroundColor() - } - val firstImage = Image( firstFrame.image, firstFrame.width, @@ -892,245 +962,141 @@ open class WebGpuViewer( backgroundColor = backgroundColor, ) - // Set position for dual page spreads based on reading direction: - // RTL (isReversed): Cover on LEFT, even=LEFT, odd=RIGHT - // LTR (!isReversed): Cover on RIGHT, even=RIGHT, odd=LEFT - firstImage.position = position + ImagePage.ImageSingle(firstImage) + } else { + val frames = ArrayList>(pageCount) - // Create ImagePage early so its cleanup handles all frames - imagePage = ImagePage(firstImage) + val firstImage = Image( + firstFrame.image, + firstFrame.width, + firstFrame.height, + createMipMaps = false, + backgroundColor = backgroundColor, + ) - // Create remaining frames for animation (only for animated images) - if (pageCount > 1) { - val frames = ArrayList>(pageCount) - frames.add(Pair(firstImage, firstFrame.duration)) - repeat(pageCount - 1) { - val frame = dec.decodeNext() - frames.add( - Pair( - Image( - frame.image, - frame.width, - frame.height, - false, - backgroundColor = backgroundColor, - ), - frame.duration, - ), - ) - } - imagePage.startAnimationLoop(frames) { - if (currentPage === page) pager.state.invalidate() + frames.add(Pair(firstImage, firstFrame.duration)) + + repeat(pageCount - 1) { + (page.imagePage as? ProgressPage)?.apply { + progress = (it + 1).toFloat() / pageCount + invalidate() } + val frame = dec.decodeNext() + val image = Image( + frame.image, + frame.width, + frame.height, + createMipMaps = false, + backgroundColor = firstImage.backgroundColor, + ) + frames.add(Pair(image, frame.duration)) } - synchronized(lock) { - if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) { - val oldImagePage = page.imagePage - page.imagePage = imagePage!! - imagePage = null - page.state = PageState.IDLE - if (oldImagePage !is ImagePage.Dummy) { - oldImagePage.cleanup() - } - applyWideZoomIfNeeded(page) - applyFitModeAnchor(page.imagePage) - pager.state.invalidate() - } else { - if (pageInCache(page)) page.state = PageState.IDLE - } - } + ImagePage.ImageSingle(frames) } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "decodeReaderPage error" } - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } - } finally { - imagePage?.cleanup() - } - } - - private suspend fun createTransitionPage(page: TransitionPage) { - try { - // Check if still valid - synchronized(lock) { - if (!pageInCache(page) || page.imagePage.isDecoded) { - if (pageInCache(page)) page.state = PageState.IDLE - return - } - } - - val bitmap = createBitmap(pager.state.width, pager.state.height) - val canvas = Canvas(bitmap) - canvas.drawColor(readerBackgroundColor()) - - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = readerOnBackgroundColor() - textSize = 48f - textAlign = Paint.Align.CENTER - } - - val maxWidth = bitmap.width * 0.8f - val lineHeight = 48f - - fun wrapText(text: String): List { - val words = text.split(" ") - val lines = mutableListOf() - var currentLine = StringBuilder() - for (word in words) { - val testLine = if (currentLine.isEmpty()) word else "$currentLine $word" - if (paint.measureText(testLine) <= maxWidth) { - currentLine = StringBuilder(testLine) - } else { - if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) - currentLine = StringBuilder(word) - } - } - if (currentLine.isNotEmpty()) lines.add(currentLine.toString()) - return lines - } - - val lines = mutableListOf>() - page.prevChapter?.chapter?.let { chapter -> - lines.add(Pair("Previous:", lineHeight)) - wrapText(chapter.name).forEach { lines.add(Pair(it, lineHeight)) } - page.nextChapter?.chapter?.let { lines.add(Pair("", lineHeight)) } - } - page.nextChapter?.chapter?.let { chapter -> - lines.add(Pair("Next:", lineHeight)) - wrapText(chapter.name).forEach { lines.add(Pair(it, lineHeight)) } - } - - val x = bitmap.width / 2f - var y = bitmap.height / 2f - lines.sumOf { it.second.toDouble() }.toFloat() / 2 - lines.forEach { - canvas.drawText(it.first, x, y + it.second, paint) - y += it.second - } - - val imagePage = ImagePage(bitmap, createMipMaps = false) - imagePage.image?.position = Image.Position.SINGLE - imagePage.highQuality = false synchronized(lock) { - if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) { + if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { val oldImagePage = page.imagePage page.imagePage = imagePage page.state = PageState.IDLE - if (oldImagePage !is ImagePage.Dummy) { - oldImagePage.cleanup() + oldImagePage.cleanup() + if (page.spreadPosition == SpreadPosition.SINGLE) { + (page.imagePage as? ImagePage.ImageSingle)?.let { + if (!applyWideZoomIfNeeded(it)) { + applyFitModeAnchor(it) + } + } } pager.state.invalidate() } else { if (pageInCache(page)) page.state = PageState.IDLE - imagePage.cleanup() } } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "createTransitionPage error" } - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } } } - private fun applyWideZoomIfNeeded(page: ViewerReaderPage) { - if (!config.landscapeZoom) return - val imagePage = page.imagePage - val image = imagePage.image ?: return - if (image.position != Image.Position.SINGLE) return + private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean { + if (!config.landscapeZoom) return false val screenW = pager.state.width - val screenH = pager.state.height - if (screenW <= 0 || screenH <= 0) return + val screenH = pager.state.viewportHeight + if (screenW <= 0 || screenH <= 0) return false + + // don't zoom if it fits at original scale + if (page.trimWidth <= screenW) return false + + val image = page.image ?: return false + + // if the untrimmed page is wide and the trimmed page is wide + val aspectRatio = min( + page.trimWidth.toFloat() / page.trimHeight.toFloat(), + image.width.toFloat() / image.height.toFloat(), + ) // Wide page: half the image width is wider than the screen aspect ratio - if (image.width.toFloat() / image.height <= 2f * screenW.toFloat() / screenH) return + if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false + + // need to set parent for positioning to work + page.parent = pager.state // Scale to fit half the image width to the full screen width - val wideScale = screenW.toFloat() / (image.width / 2f) + page.homeScale = screenW.toFloat() / (page.trimWidth / 2f) - val halfOffset = (image.width / 4f) / screenW - val startX = when (config.imageZoomType) { - ZoomStartPosition.LEFT -> halfOffset - ZoomStartPosition.RIGHT -> -halfOffset + page.scale = page.homeScale + + val minX = page.minX(page.homeScale) + val maxX = page.maxX(page.homeScale) + + page.x = when (config.imageZoomType) { + ZoomStartPosition.LEFT -> maxX + ZoomStartPosition.RIGHT -> minX ZoomStartPosition.CENTER -> 0f } - imagePage.homeScaleOverride = wideScale - imagePage.homeXOverride = startX - imagePage.scale = wideScale - imagePage.x = startX + page.y = page.homeY - imagePage.y = run { - val cutoutTopPx = pager.state.cutoutTopPx - if (cutoutTopPx <= 0f) return@run 0f - val trimTop = image.trim?.top ?: 0 - val imageOnScreen = image.height * wideScale - val imageTopY = (screenH - imageOnScreen) / 2f - val trimTopY = imageTopY + trimTop * wideScale - if (trimTopY < cutoutTopPx) (cutoutTopPx - trimTopY) / (wideScale * screenH) else 0f - } + return true } - private fun applyFitModeAnchor(page: ImagePage) { - if (page.homeScaleOverride != null) return - + private fun applyFitModeAnchor(page: ImagePage.ImageSingle) { val scaleType = config.imageScaleType if (scaleType != 3 && scaleType != 4 && scaleType != 5) return - val image = page.image ?: return - if (image.position != Image.Position.SINGLE) return - val screenW = pager.state.width - val screenH = pager.state.height + val screenH = pager.state.viewportHeight if (screenW <= 0 || screenH <= 0) return val w = page.trimWidth.toFloat() val h = page.trimHeight.toFloat() if (w <= 0f || h <= 0f) return - val cutoutTopPx = pager.state.cutoutTopPx - val contentW = screenW.toFloat() - val contentH = if (pager.state.avoidCutout && cutoutTopPx > 0f) screenH - cutoutTopPx else screenH.toFloat() + page.parent = pager.state - val homeScale = when (scaleType) { - 3 -> contentW / w - 4 -> contentH / h + page.homeScale = when (scaleType) { + 3 -> screenW / w + 4 -> screenH / h else -> 1f // original size }.coerceAtLeast(0.01f) - page.homeScaleOverride = homeScale + + page.scale = page.homeScale if (scaleType == 5) { // original size - val minScaleComputed = minOf(contentW / page.width, contentH / page.height).coerceAtLeast(0.01f) - if (homeScale < minScaleComputed) { - page.minScale = homeScale + if (page.homeScale < page.minScale) { + page.minScale = page.homeScale } } - // zoom start for fit height/original size - page.homeXOverride = if (scaleType == 4 || scaleType == 5) { - val maxX = maxOf(0f, (page.width.toFloat() / screenW - 1f / homeScale) / 2f) - when (config.imageZoomType) { - ZoomStartPosition.LEFT -> maxX - ZoomStartPosition.RIGHT -> -maxX - ZoomStartPosition.CENTER -> 0f - } - } else { - null + val minX = page.minX(page.homeScale) + val maxX = page.maxX(page.homeScale) + + page.x = when (config.imageZoomType) { + ZoomStartPosition.LEFT -> maxX + ZoomStartPosition.RIGHT -> minX + ZoomStartPosition.CENTER -> 0f } - // push below cutout for fit width/original size - val trimTop = image.trim?.top ?: 0 - val imageTopY = (screenH - page.height * homeScale) / 2f - val trimTopY = imageTopY + trimTop * homeScale - page.homeYOverride = if ((scaleType == 3 || scaleType == 5) && h * homeScale > screenH) { - val target = if (pager.state.avoidCutout && cutoutTopPx > 0f) { - if (pager.state.alwaysAvoidCutout) cutoutTopPx / 2f else cutoutTopPx - } else { - 0f - } - maxOf(0f, (target - trimTopY) / (homeScale * screenH)) - } else { - null - } + page.y = page.homeY } /** @@ -1139,7 +1105,9 @@ open class WebGpuViewer( protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) { synchronized(lock) { val cachedPage = findInCache(pageKey(page)) ?: return - queueForDecode(cachedPage, prioritize) + if (cachedPage is ViewerReaderPage) { + queueForDecode(cachedPage, prioritize) + } } } @@ -1212,8 +1180,8 @@ open class WebGpuViewer( (page as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } preloadPages(page) - (page as? TransitionPage)?.let { transitionPage -> - if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + (page as? ViewerTransitionPage)?.let { ViewerTransitionPage -> + if (ViewerTransitionPage.prevChapter == null || ViewerTransitionPage.nextChapter == null) { activity.showMenu() } } @@ -1239,8 +1207,8 @@ open class WebGpuViewer( (newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } preloadPages(newPage) - (newPage as? TransitionPage)?.let { transitionPage -> - if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + (newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage -> + if (ViewerTransitionPage.prevChapter == null || ViewerTransitionPage.nextChapter == null) { activity.showMenu() } } @@ -1258,13 +1226,17 @@ open class WebGpuViewer( -1 } - is TransitionPage if newPage is ViewerReaderPage -> if (previousPage.nextChapter == newPage.page.chapter) { + is ViewerTransitionPage if newPage is ViewerReaderPage -> if (previousPage.nextChapter == + newPage.page.chapter + ) { 1 } else { -1 } - is ViewerReaderPage if newPage is TransitionPage -> if (previousPage.page.chapter == newPage.prevChapter) { + is ViewerReaderPage if newPage is ViewerTransitionPage -> if (previousPage.page.chapter == + newPage.prevChapter + ) { 1 } else { -1 @@ -1300,18 +1272,17 @@ open class WebGpuViewer( */ protected open fun moveRight() { pager.state.getPage(0)?.let { page -> - val isWidePage = page.homeScaleOverride != null - if (config.navigateToPan && (!page.atHome || isWidePage)) { + if (config.navigateToPan) { + val minX = page.minX(page.scale) val maxX = page.maxX(page.scale) + val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x + val c = if (isReversed) -1 else 1 - val x = (page.x - c / page.scale).coerceIn(-maxX, maxX) - if (x != page.x) { - if (page.animationJob?.isActive == true && page.animationTargetX == x) { - page.animationJob?.cancel() - } else { - page.animateTo(targetX = x, targetY = page.y) - return - } + val x = (currentX - c / page.scale).coerceIn(minX, maxX) + + if (!currentX.closeTo(x)) { + page.animateTo(targetX = x, targetY = page.y) + return } } @@ -1324,18 +1295,17 @@ open class WebGpuViewer( */ protected open fun moveLeft() { pager.state.getPage(0)?.let { page -> - val isWidePage = page.homeScaleOverride != null - if (config.navigateToPan && (!page.atHome || isWidePage)) { + if (config.navigateToPan) { + val minX = page.minX(page.scale) val maxX = page.maxX(page.scale) + val currentX = page.animationJob?.isActive?.let { page.animationTargetX } ?: page.x + val c = if (isReversed) -1 else 1 - val x = (page.x + c / page.scale).coerceIn(-maxX, maxX) - if (x != page.x) { - if (page.animationJob?.isActive == true && page.animationTargetX == x) { - page.animationJob?.cancel() - } else { - page.animateTo(targetX = x, targetY = page.y) - return - } + val x = (currentX + c / page.scale).coerceIn(minX, maxX) + + if (!currentX.closeTo(x)) { + page.animateTo(targetX = x, targetY = page.y) + return } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24fc0ecd7..21da635c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ firebase-bom = "34.18.0" firebase-crashlytics = "3.0.8" flexibleAdapter = "c8013533" google-services = "4.5.0" -image-decoder = "9" +image-decoder = "10" injekt = "91edab2317" jsoup = "1.23.1" junit = "6.1.3" @@ -76,7 +76,7 @@ swipe = "1.3.0" tapmoc = "0.4.2" unifile = "08f224c8f9" voyager = "2.2.21-1.10.3" -webgpuviewer = "36" +webgpuviewer = "38" xmlutil = "1.0.2" [libraries]