diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt index d757c1be3..0cb032f88 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt @@ -31,6 +31,7 @@ import java.text.NumberFormat internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { HeadingItem(MR.strings.pref_category_for_this_series) val manga by viewModel.mangaFlow.collectAsState() + val viewer by viewModel.viewerFlow.collectAsState() val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } SettingsChipRow(MR.strings.pref_category_reading_mode) { @@ -43,22 +44,24 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() - val resolved = ReadingMode.fromPreference( - when { - readingMode == ReadingMode.DEFAULT -> default - else -> manga?.readingMode?.toInt() ?: default - }, - ) - if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { - val dualPageView by viewModel.preferences.dualPageView.collectAsState() - SettingsChipRow(MR.strings.pref_dual_page_view) { - ReaderPreferences.DualPageView.entries.map { - FilterChip( - selected = it == dualPageView, - onClick = { viewModel.preferences.dualPageView.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) + if (viewer is WebGpuViewer) { + val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() + val resolved = ReadingMode.fromPreference( + when { + readingMode == ReadingMode.DEFAULT -> default + else -> manga?.readingMode?.toInt() ?: default + }, + ) + if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { + val dualPageView by viewModel.preferences.dualPageView.collectAsState() + SettingsChipRow(MR.strings.pref_dual_page_view) { + ReaderPreferences.DualPageView.entries.map { + FilterChip( + selected = it == dualPageView, + onClick = { viewModel.preferences.dualPageView.set(it) }, + label = { Text(stringResource(it.titleRes)) }, + ) + } } } } @@ -74,14 +77,10 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - val viewer by viewModel.viewerFlow.collectAsState() - if (viewer is WebtoonViewer) { - WebtoonViewerSettings(viewModel) - } else { - PagerViewerSettings(viewModel) - if (viewer is WebGpuViewer) { - WebGpuViewerSettings(viewModel) - } + when (viewer) { + is WebtoonViewer -> WebtoonViewerSettings(viewModel) + is WebGpuViewer -> WebGpuViewerSettings(viewModel) + else -> PagerViewerSettings(viewModel) } } @@ -264,9 +263,57 @@ private fun ColumnScope.TapZonesItems( private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { HeadingItem(MR.strings.webgpu_viewer) + val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() + val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState() + TapZonesItems( + selected = navigationModePager, + onSelect = viewModel.preferences.navigationModePager::set, + invertMode = pagerNavInverted, + onSelectInvertMode = viewModel.preferences.pagerNavInverted::set, + ) + + val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() + SettingsChipRow(MR.strings.pref_image_scale_type) { + ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { + FilterChip( + selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it, + onClick = { + viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1) + }, + label = { Text(stringResource(it)) }, + ) + } + } + + val zoomStart by viewModel.preferences.zoomStart.collectAsState() + SettingsChipRow(MR.strings.pref_zoom_start) { + ReaderPreferences.ZoomStart.mapIndexed { index, it -> + FilterChip( + selected = zoomStart == index + 1, + onClick = { viewModel.preferences.zoomStart.set(index + 1) }, + label = { Text(stringResource(it)) }, + ) + } + } + + CheckboxItem( + label = stringResource(MR.strings.pref_crop_borders), + pref = viewModel.preferences.cropBorders, + ) + + CheckboxItem( + label = stringResource(MR.strings.pref_landscape_zoom), + pref = viewModel.preferences.landscapeZoom, + ) + + CheckboxItem( + label = stringResource(MR.strings.pref_navigate_pan), + pref = viewModel.preferences.navigateToPan, + ) + val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState() SettingsChipRow(MR.strings.pref_transition_animation) { - ReaderPreferences.TransitionAnimation.entries.map { + ReaderPreferences.TransitionAnimation.entries.forEach { FilterChip( selected = it == transitionAnimation, onClick = { viewModel.preferences.transitionAnimation.set(it) }, @@ -277,7 +324,7 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) val cutoutMode by viewModel.preferences.cutoutMode.collectAsState() SettingsChipRow(MR.strings.pref_cutout_mode) { - ReaderPreferences.CutoutMode.entries.map { + ReaderPreferences.CutoutMode.entries.forEach { FilterChip( selected = it == cutoutMode, onClick = { viewModel.preferences.cutoutMode.set(it) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 6626b7bbf..da6502b83 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -298,6 +298,13 @@ class ReaderPreferences( MR.strings.scale_type_smart_fit, ) + val ImageScaleTypeWebGpuViewer = listOf( + MR.strings.scale_type_fit_screen, + MR.strings.scale_type_fit_width, + MR.strings.scale_type_fit_height, + MR.strings.scale_type_original_size, + ) + val ZoomStart = listOf( MR.strings.zoom_start_automatic, MR.strings.zoom_start_left, 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 14a1641c9..4f9a76bec 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 @@ -214,6 +214,21 @@ open class WebGpuViewer( @Volatile var currentPage: ViewerPage? = null + /** + * What a running page turn animates away from, kept out of [evictFarthestPage]'s reach - a + * jump preloads enough pages to evict it. Replaced by the next turn's rather than cleared. + */ + @Volatile + private var pinnedFromPage: ImagePage? = null + + /** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */ + private fun isPinned(page: ViewerPage): Boolean { + val pinned = pinnedFromPage ?: return false + val image = page.imagePage + if (pinned === image) return true + return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image) + } + open val preloadAhead = 3 open val preloadBehind = 2 @@ -231,12 +246,18 @@ open class WebGpuViewer( /** * Evicts the page farthest from reference. Must be called while holding lock. + * + * Never evicts [reference], [currentPage] or what [pinnedFromPage] draws. Returns false when + * nothing was evictable, so a trim loop stops instead of spinning. + * * @param reference The page to use as reference (defaults to currentPage) */ - private fun evictFarthestPage(reference: ViewerPage? = null) { - val current = reference ?: currentPage ?: return - val candidates = pageCache.values.filter { it !== current }.toMutableSet() - if (candidates.isEmpty()) return + private fun evictFarthestPage(reference: ViewerPage? = null): Boolean { + val current = reference ?: currentPage ?: return false + val candidates = pageCache.values + .filter { it !== current && it !== currentPage && !isPinned(it) } + .toMutableSet() + if (candidates.isEmpty()) return false fun findNext(page: ViewerPage): ViewerPage? = when (page) { is ViewerReaderPage -> { @@ -305,13 +326,14 @@ open class WebGpuViewer( if (backward != null && candidates.remove(backward)) farthest = backward } - val toRemove = candidates.firstOrNull() ?: farthest ?: return + val toRemove = candidates.firstOrNull() ?: farthest ?: return false pageCache.remove(pageKey(toRemove)) decodeQueue.remove(toRemove) toRemove.state = PageState.IDLE (toRemove as? ViewerReaderPage)?.spreadPage?.cleanup() toRemove.imagePage.cleanup() + return true } /** @@ -324,7 +346,7 @@ open class WebGpuViewer( findInCache(key) ?: ViewerReaderPage(page).also { newPage -> pageCache[key] = newPage while (pageCache.size > cacheSize) { - evictFarthestPage(referencePage ?: newPage) + if (!evictFarthestPage(referencePage ?: newPage)) break } } } @@ -340,7 +362,7 @@ open class WebGpuViewer( findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage -> pageCache[key] = newPage while (pageCache.size > cacheSize) { - evictFarthestPage(referencePage ?: newPage) + if (!evictFarthestPage(referencePage ?: newPage)) break } } } @@ -367,7 +389,7 @@ open class WebGpuViewer( } inner class ErrorPage internal constructor( - var message: String, + message: String, spreadPosition: SpreadPosition = SpreadPosition.SINGLE, ) : ImagePage.Render( if (spreadPosition == SpreadPosition.SINGLE) pager.state.width else pager.state.width / 2, @@ -379,6 +401,12 @@ open class WebGpuViewer( homeScale = 1f } + var message: String = message + set(value) { + field = value + invalidate() + } + override val backgroundColor: Int = readerBackgroundColor() override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { @@ -403,18 +431,28 @@ open class WebGpuViewer( } } - inner class ProgressPage(var foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render( + inner class ProgressPage(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 } + var progress: Float = 0f + set(value) { + field = value + invalidate() + } + + var foregroundColor: Int = foregroundColor + set(value) { + field = value + invalidate() + } + override val backgroundColor: Int = readerBackgroundColor() override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { @@ -702,8 +740,8 @@ open class WebGpuViewer( NavigationRegion.MENU -> activity.toggleMenu() NavigationRegion.NEXT -> if (isReversed) moveToPrevious() else moveToNext() NavigationRegion.PREV -> if (isReversed) moveToNext() else moveToPrevious() - NavigationRegion.RIGHT -> if (isReversed) moveLeft() else moveRight() - NavigationRegion.LEFT -> if (isReversed) moveRight() else moveLeft() + NavigationRegion.RIGHT -> moveRight() + NavigationRegion.LEFT -> moveLeft() } } @@ -847,7 +885,6 @@ open class WebGpuViewer( (page.imagePage as? ProgressPage)?.apply { progress = value / 100f - invalidate() } } } @@ -979,7 +1016,6 @@ open class WebGpuViewer( repeat(pageCount - 1) { (page.imagePage as? ProgressPage)?.apply { progress = (it + 1).toFloat() / pageCount - invalidate() } val frame = dec.decodeNext() val image = Image( @@ -1001,6 +1037,9 @@ open class WebGpuViewer( page.imagePage = imagePage page.state = PageState.IDLE oldImagePage.cleanup() + // 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 (page.spreadPosition == SpreadPosition.SINGLE) { (page.imagePage as? ImagePage.ImageSingle)?.let { if (!applyWideZoomIfNeeded(it)) { @@ -1163,8 +1202,6 @@ open class WebGpuViewer( pager.state.apply { onPageChange = onPageChange@{ delta -> - activity.hideMenu() - // The viewer already showed the page at fetchPage(delta). // We need to update currentPage to match that. val current = currentPage ?: return@onPageChange @@ -1176,13 +1213,23 @@ open class WebGpuViewer( page = nextPage(page, step) ?: return@onPageChange } + // Synchronous, since the viewer walks getPage() from here - stale, and the next + // scroll step crosses the same boundary again. currentPage = page - (page as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } - preloadPages(page) - (page as? ViewerTransitionPage)?.let { ViewerTransitionPage -> - if (ViewerTransitionPage.prevChapter == null || ViewerTransitionPage.nextChapter == null) { - activity.showMenu() + // The rest ran here too, on the animation thread under the viewer's scroll lock. + // Posted in order, so nothing is skipped or reordered - and on this viewer's own + // MainScope, not the state's: that one dispatches inside the frame callback. + val settled = page + this@WebGpuViewer.scope.launch { + activity.hideMenu() + (settled as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } + preloadPages(settled) + + (settled as? ViewerTransitionPage)?.let { transitionPage -> + if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { + activity.showMenu() + } } } } @@ -1196,12 +1243,17 @@ open class WebGpuViewer( * In dual page mode, aligns to the start of the spread containing the page. */ override fun moveToPage(page: ReaderPage) { + // Pin first: resolving a target outside the cached window trims the cache. + pinnedFromPage = currentPage?.let { buildSpreadPage(it) } // Get the page and align to spread anchor based on image position moveToPage(getSpreadAnchor(getPage(page))) } private fun moveToPage(newPage: ViewerPage) { val previousPage = currentPage + // Before preloadPages below trims the cache - see [pinnedFromPage]. + val fromSpread = previousPage?.let { buildSpreadPage(it) } + pinnedFromPage = fromSpread currentPage = newPage (newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) } @@ -1245,14 +1297,19 @@ open class WebGpuViewer( else -> 0 } - if (direction != 0) { - pager.state.transitionFromPage = buildSpreadPage(previousPage) - pager.state.animatePageTurn(if (isReversed) direction else -direction) + if (direction != 0 && fromSpread != null) { + animateTurn(direction, fromSpread) } else { pager.state.invalidate() } } + /** How a [moveToPage] turn is shown. [direction] is 1 forward through the pages, -1 back. */ + protected open fun animateTurn(direction: Int, fromSpread: ImagePage) { + pager.state.transitionFromPage = fromSpread + pager.state.animatePageTurn(if (isReversed) direction else -direction) + } + /** * Moves to the next page. */ @@ -1277,8 +1334,7 @@ open class WebGpuViewer( val maxX = page.maxX(page.scale) val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x - val c = if (isReversed) -1 else 1 - val x = (currentX - c / page.scale).coerceIn(minX, maxX) + val x = (currentX - 1 / page.scale).coerceIn(minX, maxX) if (!currentX.closeTo(x)) { page.animateTo(targetX = x, targetY = page.y) @@ -1286,7 +1342,7 @@ open class WebGpuViewer( } } - navigateSpread(1) + navigateSpread(if (isReversed) -1 else 1) } } @@ -1300,8 +1356,7 @@ open class WebGpuViewer( 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 = (currentX + c / page.scale).coerceIn(minX, maxX) + val x = (currentX + 1 / page.scale).coerceIn(minX, maxX) if (!currentX.closeTo(x)) { page.animateTo(targetX = x, targetY = page.y) @@ -1309,7 +1364,7 @@ open class WebGpuViewer( } } - navigateSpread(-1) + navigateSpread(if (isReversed) 1 else -1) } } @@ -1372,7 +1427,7 @@ open class WebGpuViewer( if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted) moveDown() else moveUp() + if (!config.volumeKeysInverted.xor(isReversed)) moveDown() else moveUp() } } @@ -1380,7 +1435,7 @@ open class WebGpuViewer( if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted) moveUp() else moveDown() + if (!config.volumeKeysInverted.xor(isReversed)) moveUp() else moveDown() } } 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 f6fdf6db2..c0907e21b 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 @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webgpu import ca.mpreg.webgpuviewer.ImageViewContinuous +import ca.mpreg.webgpuviewer.viewer.ImagePage import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.model.ReaderPage @@ -9,13 +10,13 @@ class WebGpuViewerContinuous(activity: ReaderActivity) : override val isContinuous: Boolean = true - override val preloadAhead = 2 + override val preloadAhead = 3 override val preloadBehind = 1 + private val state get() = (pager as ImageViewContinuous).state + private fun scrollByHalfPage(direction: Int) { - val state = (pager as ImageViewContinuous).state - val totalDistance = direction * state.height / 2f - state.animateScroll(totalDistance) + state.animateScroll(direction * state.height / 2f) } override fun moveRight() = scrollByHalfPage(1) @@ -24,6 +25,11 @@ class WebGpuViewerContinuous(activity: ReaderActivity) : override fun moveToPage(page: ReaderPage) { super.moveToPage(page) - (pager as ImageViewContinuous).state.scrollY = 0f + // Also for a jump to the page already showing, which turns nothing to slide in. + state.resetScroll() + } + + override fun animateTurn(direction: Int, fromSpread: ImagePage) { + state.animateSlideIn(direction) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95658715b..4d366283b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -76,7 +76,7 @@ swipe = "1.3.0" tapmoc = "0.4.2" unifile = "08f224c8f9" voyager = "2.2.21-1.10.3" -webgpuviewer = "38" +webgpuviewer = "39" xmlutil = "1.0.2" [libraries]