Bump webgpuviewer (#3933)
* add disable zoom out * add hdr * Fix dual mode page progress and cleanup * webgpu viewer add page gap setting * Update libs.versions.toml
This commit is contained in:
@@ -35,6 +35,7 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import ca.mpreg.webgpuviewer.renderer.GainmapInput
|
||||||
import ca.mpreg.webgpuviewer.renderer.Image
|
import ca.mpreg.webgpuviewer.renderer.Image
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImageViewer
|
import ca.mpreg.webgpuviewer.viewer.ImageViewer
|
||||||
@@ -180,6 +181,21 @@ fun MangaCoverDialog(
|
|||||||
res.height,
|
res.height,
|
||||||
createMipMaps = true,
|
createMipMaps = true,
|
||||||
backgroundColor = 0,
|
backgroundColor = 0,
|
||||||
|
hdr = res.isHdr,
|
||||||
|
hdrHeadroom = res.hdrHeadroom,
|
||||||
|
gainmap = res.gainmap?.let {
|
||||||
|
GainmapInput(
|
||||||
|
pixels = it.pixels,
|
||||||
|
width = it.width,
|
||||||
|
height = it.height,
|
||||||
|
channels = it.channels,
|
||||||
|
gamma = it.gamma,
|
||||||
|
minContentBoost = it.minContentBoost,
|
||||||
|
maxContentBoost = it.maxContentBoost,
|
||||||
|
offsetSdr = it.offsetSdr,
|
||||||
|
offsetHdr = it.offsetHdr,
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved == ReadingMode.WEBTOON) {
|
if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||||
val numberFormat = remember { NumberFormat.getPercentInstance() }
|
val numberFormat = remember { NumberFormat.getPercentInstance() }
|
||||||
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
|
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
|
||||||
SliderItem(
|
SliderItem(
|
||||||
@@ -78,6 +78,25 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
|||||||
},
|
},
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||||
|
val continuousGap by viewModel.preferences.continuousGap.collectAsState()
|
||||||
|
SliderItem(
|
||||||
|
value = continuousGap,
|
||||||
|
valueRange = ReaderPreferences.let { 1..100 },
|
||||||
|
label = stringResource(MR.strings.pref_continuous_gap),
|
||||||
|
valueString = numberFormat.format(continuousGap / 100f),
|
||||||
|
onChange = {
|
||||||
|
viewModel.preferences.continuousGap.set(it)
|
||||||
|
},
|
||||||
|
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckboxItem(
|
||||||
|
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
|
||||||
|
pref = viewModel.preferences.webtoonDisableZoomOut,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +297,17 @@ private fun ColumnScope.TapZonesItems(
|
|||||||
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
|
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
|
||||||
HeadingItem(MR.strings.webgpu_viewer)
|
HeadingItem(MR.strings.webgpu_viewer)
|
||||||
|
|
||||||
|
val manga by viewModel.mangaFlow.collectAsState()
|
||||||
val viewer by viewModel.viewerFlow.collectAsState()
|
val viewer by viewModel.viewerFlow.collectAsState()
|
||||||
|
|
||||||
|
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||||
|
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
|
||||||
|
val resolved = ReadingMode.fromPreference(
|
||||||
|
when {
|
||||||
|
readingMode == ReadingMode.DEFAULT -> default
|
||||||
|
else -> manga?.readingMode?.toInt() ?: default
|
||||||
|
},
|
||||||
|
)
|
||||||
val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
|
val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
|
||||||
|
|
||||||
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
|
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
|
||||||
@@ -318,6 +346,7 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
|
||||||
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
|
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
|
||||||
SettingsChipRow(MR.strings.pref_image_scale_type) {
|
SettingsChipRow(MR.strings.pref_image_scale_type) {
|
||||||
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
|
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
|
||||||
@@ -378,4 +407,5 @@ private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ class ReaderPreferences(
|
|||||||
|
|
||||||
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
|
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
|
||||||
|
|
||||||
|
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
enum class FlashColor {
|
enum class FlashColor {
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ enum class ReadingMode(
|
|||||||
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
|
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
|
||||||
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
|
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
|
||||||
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
|
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
|
||||||
WEBTOON -> WebGpuViewerContinuous(activity)
|
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
|
||||||
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity)
|
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
|
||||||
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
|
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,17 @@ class WebGpuConfig(
|
|||||||
var dualPageView = ReaderPreferences.DualPageView.NEVER
|
var dualPageView = ReaderPreferences.DualPageView.NEVER
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var continuousMinWidth = 1
|
var continuousMinWidth = 100
|
||||||
|
private set
|
||||||
|
|
||||||
|
var zoomOutDisabled = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
var continuousGap = 10
|
||||||
private set
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
readerPreferences.readerTheme
|
readerPreferences.readerTheme.register(
|
||||||
.register(
|
|
||||||
{
|
{
|
||||||
theme = it
|
theme = it
|
||||||
automaticBackground = it == 3
|
automaticBackground = it == 3
|
||||||
@@ -74,33 +79,23 @@ class WebGpuConfig(
|
|||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.imageScaleType
|
readerPreferences.imageScaleType.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
||||||
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.zoomStart
|
readerPreferences.zoomStart.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
||||||
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.cropBorders
|
readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||||
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.navigateToPan
|
readerPreferences.navigateToPan.register({ navigateToPan = it })
|
||||||
.register({ navigateToPan = it })
|
|
||||||
|
|
||||||
readerPreferences.landscapeZoom
|
readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
|
||||||
.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.navigationModePager
|
readerPreferences.navigationModePager.register({ navigationMode = it }, { updateNavigation(navigationMode) })
|
||||||
.register({ navigationMode = it }, { updateNavigation(navigationMode) })
|
|
||||||
|
|
||||||
readerPreferences.pagerNavInverted
|
readerPreferences.pagerNavInverted.register({ tappingInverted = it }, { navigator.invertMode = it })
|
||||||
.register({ tappingInverted = it }, { navigator.invertMode = it })
|
readerPreferences.pagerNavInverted.changes().drop(1).onEach { navigationModeChangedListener?.invoke() }
|
||||||
readerPreferences.pagerNavInverted.changes()
|
|
||||||
.drop(1)
|
|
||||||
.onEach { navigationModeChangedListener?.invoke() }
|
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
|
|
||||||
readerPreferences.dualPageSplitPaged
|
readerPreferences.dualPageSplitPaged.register(
|
||||||
.register(
|
|
||||||
{ dualPageSplit = it },
|
{ dualPageSplit = it },
|
||||||
{
|
{
|
||||||
imagePropertyChangedListener?.invoke()
|
imagePropertyChangedListener?.invoke()
|
||||||
@@ -108,56 +103,60 @@ class WebGpuConfig(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.dualPageInvertPaged
|
readerPreferences.dualPageInvertPaged.register(
|
||||||
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
|
{ dualPageInvert = it },
|
||||||
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
|
)
|
||||||
|
|
||||||
readerPreferences.dualPageRotateToFit
|
readerPreferences.dualPageRotateToFit.register(
|
||||||
.register(
|
|
||||||
{ dualPageRotateToFit = it },
|
{ dualPageRotateToFit = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.dualPageRotateToFitInvert
|
readerPreferences.dualPageRotateToFitInvert.register(
|
||||||
.register(
|
|
||||||
{ dualPageRotateToFitInvert = it },
|
{ dualPageRotateToFitInvert = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.transitionAnimation
|
readerPreferences.transitionAnimation.register(
|
||||||
.register(
|
|
||||||
{ transitionAnimation = it },
|
{ transitionAnimation = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.transitionAnimationDual
|
readerPreferences.transitionAnimationDual.register(
|
||||||
.register(
|
|
||||||
{ transitionAnimationDual = it },
|
{ transitionAnimationDual = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.cutoutMode
|
readerPreferences.cutoutMode.register(
|
||||||
.register(
|
|
||||||
{ cutoutMode = it },
|
{ cutoutMode = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.cutoutModeDual
|
readerPreferences.cutoutModeDual.register(
|
||||||
.register(
|
|
||||||
{ cutoutModeDual = it },
|
{ cutoutModeDual = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.dualPageView
|
readerPreferences.dualPageView.register(
|
||||||
.register(
|
|
||||||
{ dualPageView = it },
|
{ dualPageView = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
readerPreferences.continuousMinWidth
|
readerPreferences.continuousMinWidth.register(
|
||||||
.register(
|
|
||||||
{ continuousMinWidth = it },
|
{ continuousMinWidth = it },
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readerPreferences.webtoonDisableZoomOut.register(
|
||||||
|
{ zoomOutDisabled = it },
|
||||||
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
|
)
|
||||||
|
|
||||||
|
readerPreferences.continuousGap.register(
|
||||||
|
{ continuousGap = it },
|
||||||
|
{ imagePropertyChangedListener?.invoke() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun zoomTypeFromPreference(value: Int) {
|
private fun zoomTypeFromPreference(value: Int) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import ca.mpreg.imagedecoder.ImageDecoder
|
|||||||
import ca.mpreg.webgpuviewer.ImageView
|
import ca.mpreg.webgpuviewer.ImageView
|
||||||
import ca.mpreg.webgpuviewer.closeTo
|
import ca.mpreg.webgpuviewer.closeTo
|
||||||
import ca.mpreg.webgpuviewer.draw.TextAlign
|
import ca.mpreg.webgpuviewer.draw.TextAlign
|
||||||
|
import ca.mpreg.webgpuviewer.renderer.GainmapInput
|
||||||
import ca.mpreg.webgpuviewer.renderer.Image
|
import ca.mpreg.webgpuviewer.renderer.Image
|
||||||
import ca.mpreg.webgpuviewer.transition.TransitionBasic
|
import ca.mpreg.webgpuviewer.transition.TransitionBasic
|
||||||
import ca.mpreg.webgpuviewer.transition.TransitionCube
|
import ca.mpreg.webgpuviewer.transition.TransitionCube
|
||||||
@@ -47,6 +48,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
|||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||||
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
|
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
|
||||||
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
@@ -74,13 +76,22 @@ open class WebGpuViewer(
|
|||||||
|
|
||||||
val readerPreferences by lazy { activity.appGraph.readerPreferences }
|
val readerPreferences by lazy { activity.appGraph.readerPreferences }
|
||||||
|
|
||||||
private fun readerBackgroundColor(): Int = activity.baseContext.readerBackgroundColor(config.theme)
|
/** Resolved once: render() asks per frame, and createReaderThemeContext builds a Resources. */
|
||||||
|
@Volatile
|
||||||
|
private var cachedBackgroundColor: Int? = null
|
||||||
|
|
||||||
private fun readerOnBackgroundColor(): Int = MaterialColors.getColor(
|
@Volatile
|
||||||
|
private var cachedOnBackgroundColor: Int? = null
|
||||||
|
|
||||||
|
private fun readerBackgroundColor(): Int =
|
||||||
|
cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme)
|
||||||
|
.also { cachedBackgroundColor = it }
|
||||||
|
|
||||||
|
private fun readerOnBackgroundColor(): Int = cachedOnBackgroundColor ?: MaterialColors.getColor(
|
||||||
activity.createReaderThemeContext(),
|
activity.createReaderThemeContext(),
|
||||||
com.google.android.material.R.attr.colorOnBackground,
|
com.google.android.material.R.attr.colorOnBackground,
|
||||||
Color.WHITE,
|
Color.WHITE,
|
||||||
)
|
).also { cachedOnBackgroundColor = it }
|
||||||
|
|
||||||
private val scope = MainScope()
|
private val scope = MainScope()
|
||||||
|
|
||||||
@@ -90,13 +101,16 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher()
|
private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher()
|
||||||
|
|
||||||
// Single lock for all page cache and queue operations
|
// Guards pageCache, decodeQueue, deferredCleanup and chapterPreloadsInFlight.
|
||||||
private val lock = Object()
|
private val lock = Object()
|
||||||
|
|
||||||
// Page cache - keyed by stable PageKey for O(1) lookup
|
/** Without it the worker parks in [lock].wait() after [destroy], keeping the activity alive. */
|
||||||
|
@Volatile
|
||||||
|
private var destroyed = false
|
||||||
|
|
||||||
private val pageCache = LinkedHashMap<PageKey, ViewerPage>()
|
private val pageCache = LinkedHashMap<PageKey, ViewerPage>()
|
||||||
|
|
||||||
// Decode queue - pages waiting to be decoded, processed LIFO (last = highest priority)
|
// Processed LIFO - last added is highest priority.
|
||||||
private val decodeQueue = ArrayDeque<ViewerReaderPage>()
|
private val decodeQueue = ArrayDeque<ViewerReaderPage>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +119,9 @@ open class WebGpuViewer(
|
|||||||
*/
|
*/
|
||||||
private val loneIndices = HashMap<Long?, TreeSet<Int>>()
|
private val loneIndices = HashMap<Long?, TreeSet<Int>>()
|
||||||
|
|
||||||
|
/** Chapters [preloadChapterThenRetry] is already waiting on, by id. */
|
||||||
|
private val chapterPreloadsInFlight = HashSet<Long?>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping
|
* Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping
|
||||||
* for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself.
|
* for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself.
|
||||||
@@ -117,7 +134,6 @@ open class WebGpuViewer(
|
|||||||
/** How far two untagged pages' aspect ratios may differ and still pair. */
|
/** How far two untagged pages' aspect ratios may differ and still pair. */
|
||||||
private val pairAspectTolerance = 0.1f
|
private val pairAspectTolerance = 0.1f
|
||||||
|
|
||||||
// Stable key types for page identity - data classes provide correct equals/hashCode
|
|
||||||
private sealed class PageKey {
|
private sealed class PageKey {
|
||||||
data class Reader(val chapterId: Long?, val index: Int) : PageKey()
|
data class Reader(val chapterId: Long?, val index: Int) : PageKey()
|
||||||
data class Transition(val prevId: Long?, val nextId: Long?) : PageKey()
|
data class Transition(val prevId: Long?, val nextId: Long?) : PageKey()
|
||||||
@@ -155,44 +171,39 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PageState.QUEUED -> {
|
PageState.QUEUED -> {
|
||||||
// Already queued - move to front if prioritizing
|
|
||||||
if (prioritize && decodeQueue.remove(page)) {
|
if (prioritize && decodeQueue.remove(page)) {
|
||||||
decodeQueue.addLast(page)
|
decodeQueue.addLast(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PageState.LOADING, PageState.DECODING -> {
|
PageState.LOADING, PageState.DECODING -> {}
|
||||||
// Already being processed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Decode worker thread - processes pages from the queue
|
|
||||||
scope.launch(decodeDispatcher) {
|
scope.launch(decodeDispatcher) {
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (!destroyed) {
|
||||||
|
// Popped, vetted and marked under one acquisition - no window for an eviction.
|
||||||
val page = synchronized(lock) {
|
val page = synchronized(lock) {
|
||||||
while (decodeQueue.isEmpty()) {
|
while (decodeQueue.isEmpty()) {
|
||||||
|
if (destroyed) return@launch
|
||||||
lock.wait()
|
lock.wait()
|
||||||
}
|
}
|
||||||
decodeQueue.removeLast().apply { state = PageState.DECODING }
|
val candidate = decodeQueue.removeLast()
|
||||||
}
|
if (pageInCache(candidate) && !candidate.isDecoded) {
|
||||||
|
candidate.apply { state = PageState.DECODING }
|
||||||
// Verify page is still valid (not evicted and doesn't have a decoded image yet)
|
} else {
|
||||||
val shouldProcess = synchronized(lock) {
|
if (pageInCache(candidate)) candidate.state = PageState.IDLE
|
||||||
pageInCache(page) && page.state == PageState.DECODING && !page.isDecoded
|
null
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldProcess) {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (pageInCache(page)) page.state = PageState.IDLE
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
} ?: continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
decodeReaderPage(page)
|
decodeReaderPage(page)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Caught below, the loop would park in wait() on a dead scope.
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" }
|
logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" }
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
@@ -201,7 +212,7 @@ open class WebGpuViewer(
|
|||||||
val errorMessage = e.message ?: "Failed to decode image"
|
val errorMessage = e.message ?: "Failed to decode image"
|
||||||
page.imagePage = ErrorPage(errorMessage, page.spreadPosition)
|
page.imagePage = ErrorPage(errorMessage, page.spreadPosition)
|
||||||
page.state = PageState.IDLE
|
page.state = PageState.IDLE
|
||||||
oldImagePage.cleanup()
|
cleanupImage(oldImagePage)
|
||||||
page.imagePage.invalidate()
|
page.imagePage.invalidate()
|
||||||
} else {
|
} else {
|
||||||
if (pageInCache(page)) page.state = PageState.IDLE
|
if (pageInCache(page)) page.state = PageState.IDLE
|
||||||
@@ -210,7 +221,9 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
// Normal shutdown
|
// destroy()'s shutdownNow, out of lock.wait().
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
// Scope cancelled with the viewer.
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Decode worker died" }
|
logcat(LogPriority.ERROR, e) { "Decode worker died" }
|
||||||
}
|
}
|
||||||
@@ -222,6 +235,8 @@ open class WebGpuViewer(
|
|||||||
*/
|
*/
|
||||||
val config = WebGpuConfig(this, scope, readerPreferences)
|
val config = WebGpuConfig(this, scope, readerPreferences)
|
||||||
|
|
||||||
|
// Read from the render and decode threads, via the prevChapter/nextChapter getters.
|
||||||
|
@Volatile
|
||||||
var viewerChapters: ViewerChapters? = null
|
var viewerChapters: ViewerChapters? = null
|
||||||
|
|
||||||
val pages: List<ReaderPage>? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages
|
val pages: List<ReaderPage>? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages
|
||||||
@@ -236,14 +251,36 @@ open class WebGpuViewer(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var pinnedFromPage: ImagePage? = null
|
private var pinnedFromPage: ImagePage? = null
|
||||||
|
|
||||||
/** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */
|
/** True while [pinnedFromPage] is drawing [image], as itself or as a spread side. */
|
||||||
private fun isPinned(page: ViewerPage): Boolean {
|
private fun isPinnedImage(image: ImagePage): Boolean {
|
||||||
val pinned = pinnedFromPage ?: return false
|
val pinned = pinnedFromPage ?: return false
|
||||||
val image = page.imagePage
|
|
||||||
if (pinned === image) return true
|
if (pinned === image) return true
|
||||||
return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image)
|
return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */
|
||||||
|
private fun isPinned(page: ViewerPage): Boolean = isPinnedImage(page.imagePage)
|
||||||
|
|
||||||
|
/** Images swapped out while [pinnedFromPage] was still drawing them, i.e. mid page turn. */
|
||||||
|
private val deferredCleanup = mutableListOf<ImagePage>()
|
||||||
|
|
||||||
|
/** Cleans up [image], or defers it while [pinnedFromPage] draws it. Call under [lock]. */
|
||||||
|
private fun cleanupImage(image: ImagePage) {
|
||||||
|
if (isPinnedImage(image)) deferredCleanup.add(image) else image.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases what [pinnedFromPage] no longer protects. Call under [lock]. */
|
||||||
|
private fun flushDeferredCleanup() {
|
||||||
|
val iterator = deferredCleanup.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val image = iterator.next()
|
||||||
|
if (!isPinnedImage(image)) {
|
||||||
|
image.cleanup()
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open val preloadAhead = 3
|
open val preloadAhead = 3
|
||||||
open val preloadBehind = 2
|
open val preloadBehind = 2
|
||||||
|
|
||||||
@@ -253,9 +290,6 @@ open class WebGpuViewer(
|
|||||||
*/
|
*/
|
||||||
open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1
|
open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1
|
||||||
|
|
||||||
/**
|
|
||||||
* Page processing state
|
|
||||||
*/
|
|
||||||
enum class PageState {
|
enum class PageState {
|
||||||
IDLE,
|
IDLE,
|
||||||
QUEUED,
|
QUEUED,
|
||||||
@@ -277,6 +311,9 @@ open class WebGpuViewer(
|
|||||||
pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet()
|
pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet()
|
||||||
if (candidates.isEmpty()) return false
|
if (candidates.isEmpty()) return false
|
||||||
|
|
||||||
|
// Read once - the getter measures the viewport.
|
||||||
|
val reach = cacheSize
|
||||||
|
|
||||||
fun findNext(page: ViewerPage): ViewerPage? = when (page) {
|
fun findNext(page: ViewerPage): ViewerPage? = when (page) {
|
||||||
is ViewerReaderPage -> {
|
is ViewerReaderPage -> {
|
||||||
val chapterId = page.page.chapter.chapter.id
|
val chapterId = page.page.chapter.chapter.id
|
||||||
@@ -335,7 +372,7 @@ open class WebGpuViewer(
|
|||||||
var forward: ViewerPage? = current
|
var forward: ViewerPage? = current
|
||||||
var backward: ViewerPage? = current
|
var backward: ViewerPage? = current
|
||||||
|
|
||||||
for (i in 0 until cacheSize) {
|
for (i in 0 until reach) {
|
||||||
if (candidates.isEmpty()) break
|
if (candidates.isEmpty()) break
|
||||||
forward = forward?.let { findNext(it) }
|
forward = forward?.let { findNext(it) }
|
||||||
backward = backward?.let { findPrev(it) }
|
backward = backward?.let { findPrev(it) }
|
||||||
@@ -349,8 +386,9 @@ open class WebGpuViewer(
|
|||||||
pageCache.remove(pageKey(toRemove))
|
pageCache.remove(pageKey(toRemove))
|
||||||
decodeQueue.remove(toRemove)
|
decodeQueue.remove(toRemove)
|
||||||
toRemove.state = PageState.IDLE
|
toRemove.state = PageState.IDLE
|
||||||
(toRemove as? ViewerReaderPage)?.spreadPage?.cleanup()
|
// Through the pin: a decode that swapped this page's image leaves its spread unguarded.
|
||||||
toRemove.imagePage.cleanup()
|
(toRemove as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage)
|
||||||
|
cleanupImage(toRemove.imagePage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +401,8 @@ open class WebGpuViewer(
|
|||||||
return synchronized(lock) {
|
return synchronized(lock) {
|
||||||
findInCache(key) ?: ViewerReaderPage(page).also { newPage ->
|
findInCache(key) ?: ViewerReaderPage(page).also { newPage ->
|
||||||
pageCache[key] = newPage
|
pageCache[key] = newPage
|
||||||
while (pageCache.size > cacheSize) {
|
val limit = cacheSize
|
||||||
|
while (pageCache.size > limit) {
|
||||||
if (!evictFarthestPage(referencePage ?: newPage)) break
|
if (!evictFarthestPage(referencePage ?: newPage)) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +418,8 @@ open class WebGpuViewer(
|
|||||||
return synchronized(lock) {
|
return synchronized(lock) {
|
||||||
findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage ->
|
findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage ->
|
||||||
pageCache[key] = newPage
|
pageCache[key] = newPage
|
||||||
while (pageCache.size > cacheSize) {
|
val limit = cacheSize
|
||||||
|
while (pageCache.size > limit) {
|
||||||
if (!evictFarthestPage(referencePage ?: newPage)) break
|
if (!evictFarthestPage(referencePage ?: newPage)) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +434,14 @@ open class WebGpuViewer(
|
|||||||
* decode. Gives up after 5 seconds if the chapter never finishes loading.
|
* decode. Gives up after 5 seconds if the chapter never finishes loading.
|
||||||
*/
|
*/
|
||||||
private fun preloadChapterThenRetry(chapter: ReaderChapter) {
|
private fun preloadChapterThenRetry(chapter: ReaderChapter) {
|
||||||
|
// fetchPage reaches prev/next per frame - unguarded, each frame starts another 5s poll.
|
||||||
|
val chapterId = chapter.chapter.id
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!chapterPreloadsInFlight.add(chapterId)) return
|
||||||
|
}
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
activity.viewModel.preload(chapter)
|
activity.viewModel.preload(chapter)
|
||||||
repeat(25) {
|
repeat(25) {
|
||||||
if (chapter.state is ReaderChapter.State.Loaded) {
|
if (chapter.state is ReaderChapter.State.Loaded) {
|
||||||
@@ -403,6 +450,9 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
delay(200.milliseconds)
|
delay(200.milliseconds)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
synchronized(lock) { chapterPreloadsInFlight.remove(chapterId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,8 +653,7 @@ open class WebGpuViewer(
|
|||||||
|
|
||||||
/** True when nothing may share this page's spread - it is one already. */
|
/** True when nothing may share this page's spread - it is one already. */
|
||||||
internal val standsAlone: Boolean
|
internal val standsAlone: Boolean
|
||||||
get() = taggedSpreadPosition == SpreadPosition.SINGLE ||
|
get() = taggedSpreadPosition == SpreadPosition.SINGLE || (aspectRatio ?: 0f) > wideAspect
|
||||||
(aspectRatio ?: 0f) > wideAspect
|
|
||||||
|
|
||||||
override var imagePage: ImagePage = ProgressPage()
|
override var imagePage: ImagePage = ProgressPage()
|
||||||
|
|
||||||
@@ -724,20 +773,6 @@ open class WebGpuViewer(
|
|||||||
return abs(a - b) <= pairAspectTolerance
|
return abs(a - b) <= pairAspectTolerance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given page can form a spread with the next page.
|
|
||||||
* 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
|
|
||||||
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.spreadPosition == partnerPosition && canPairShapes(page, next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the anchor page for a spread.
|
* Get the anchor page for a spread.
|
||||||
* RTL: anchor is RIGHT, for LEFT page returns previous RIGHT
|
* RTL: anchor is RIGHT, for LEFT page returns previous RIGHT
|
||||||
@@ -747,7 +782,6 @@ open class WebGpuViewer(
|
|||||||
if (!isDualPageMode()) return page
|
if (!isDualPageMode()) return page
|
||||||
if (page !is ViewerReaderPage) return page
|
if (page !is ViewerReaderPage) return page
|
||||||
|
|
||||||
// If this is a partner page, check if previous is anchor
|
|
||||||
if (page.spreadPosition == partnerPosition) {
|
if (page.spreadPosition == partnerPosition) {
|
||||||
val prev = page.prev as? ViewerReaderPage ?: return page
|
val prev = page.prev as? ViewerReaderPage ?: return page
|
||||||
if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition &&
|
if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition &&
|
||||||
@@ -757,17 +791,28 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This page is the anchor or standalone
|
|
||||||
return page
|
return page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who [page] pairs with, or null. One verdict for [buildSpreadPage] and [progressPage]. */
|
||||||
|
private fun spreadPartner(page: ViewerReaderPage): ViewerReaderPage? {
|
||||||
|
if (!isDualPageMode()) return null
|
||||||
|
if (page.spreadPosition != anchorPosition) return null
|
||||||
|
val next = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } ?: return null
|
||||||
|
return next.takeIf { it.spreadPosition == partnerPosition && canPairShapes(page, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page to report progress for - the spread's lastmost page, not the anchor. */
|
||||||
|
private fun progressPage(page: ViewerPage): ViewerReaderPage? {
|
||||||
|
val readerPage = page as? ViewerReaderPage ?: return null
|
||||||
|
return spreadPartner(readerPage) ?: readerPage
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildSpreadPage(page: ViewerPage): ImagePage {
|
private fun buildSpreadPage(page: ViewerPage): ImagePage {
|
||||||
// For ViewerTransitionPage, return its imagePage directly
|
|
||||||
if (page !is ViewerReaderPage) {
|
if (page !is ViewerReaderPage) {
|
||||||
return page.imagePage
|
return page.imagePage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only form spreads in dual page mode
|
|
||||||
if (!isDualPageMode()) {
|
if (!isDualPageMode()) {
|
||||||
return page.imagePage
|
return page.imagePage
|
||||||
}
|
}
|
||||||
@@ -782,20 +827,9 @@ open class WebGpuViewer(
|
|||||||
return imagePage
|
return imagePage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the anchor side looks for a partner on the next page. A partner-tagged page only
|
// Null for a partner reaching here directly, which means no anchor before it - a lone
|
||||||
// reaches this function directly (rather than being redirected here via
|
// RIGHT at a chapter boundary - so it draws alone on its own side.
|
||||||
// [getSpreadAnchor]) when it has no anchor before it - a lone RIGHT with no preceding
|
val partnerImagePage = spreadPartner(page)?.imagePage
|
||||||
// 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 &&
|
|
||||||
canPairShapes(page, nextReaderPage)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// LEFT/RIGHT map directly to the spread's left/right slot - independent of reading
|
// 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.
|
// direction, which only decides which side is the anchor for pairing purposes above.
|
||||||
@@ -854,6 +888,10 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.imagePropertyChangedListener = {
|
config.imagePropertyChangedListener = {
|
||||||
|
// A theme change comes through here.
|
||||||
|
cachedBackgroundColor = null
|
||||||
|
cachedOnBackgroundColor = null
|
||||||
|
|
||||||
val isDual = isDualPageMode()
|
val isDual = isDualPageMode()
|
||||||
pager.state.apply {
|
pager.state.apply {
|
||||||
transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) {
|
transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) {
|
||||||
@@ -887,8 +925,15 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
(this as? ImageViewerContinuousState)?.let {
|
(this as? ImageViewerContinuousState)?.let {
|
||||||
minZoomWidthFraction = config.continuousMinWidth / 100f
|
homeScale = config.continuousMinWidth / 100f
|
||||||
scale = minScale
|
scale = homeScale
|
||||||
|
minScale = if (config.zoomOutDisabled) 0f else 0.1f
|
||||||
|
|
||||||
|
(this@WebGpuViewer as? WebGpuViewerContinuous)?.let {
|
||||||
|
if (this@WebGpuViewer.useGap) {
|
||||||
|
pageGap = config.continuousGap / 100f
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,8 +941,8 @@ open class WebGpuViewer(
|
|||||||
decodeQueue.clear()
|
decodeQueue.clear()
|
||||||
pageCache.values.forEach {
|
pageCache.values.forEach {
|
||||||
it.state = PageState.IDLE
|
it.state = PageState.IDLE
|
||||||
(it as? ViewerReaderPage)?.spreadPage?.cleanup()
|
(it as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage)
|
||||||
it.imagePage.cleanup()
|
cleanupImage(it.imagePage)
|
||||||
}
|
}
|
||||||
pageCache.clear()
|
pageCache.clear()
|
||||||
|
|
||||||
@@ -919,44 +964,41 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun destroy() {
|
override fun destroy() {
|
||||||
// Cancel scope first to stop any new operations
|
// Before the interrupt: taken mid-decode, only the flag stops the worker parking.
|
||||||
|
destroyed = true
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|
||||||
// Shutdown decode executor with interrupt to wake up the worker from wait()
|
// shutdownNow interrupts the worker out of lock.wait().
|
||||||
decodeExecutor.shutdownNow()
|
decodeExecutor.shutdownNow()
|
||||||
decodeDispatcher.close()
|
decodeDispatcher.close()
|
||||||
|
|
||||||
// Now clean up pages (cleanup() launches fire-and-forget coroutines on Dispatchers.Default)
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
decodeQueue.clear()
|
decodeQueue.clear()
|
||||||
|
// Nothing can still be animating, so the pin has nothing left to protect.
|
||||||
|
pinnedFromPage = null
|
||||||
pageCache.values.forEach {
|
pageCache.values.forEach {
|
||||||
it.state = PageState.IDLE
|
it.state = PageState.IDLE
|
||||||
(it as? ViewerReaderPage)?.spreadPage?.cleanup()
|
(it as? ViewerReaderPage)?.spreadPage?.cleanup()
|
||||||
it.imagePage.cleanup()
|
it.imagePage.cleanup()
|
||||||
}
|
}
|
||||||
pageCache.clear()
|
pageCache.clear()
|
||||||
|
deferredCleanup.forEach { it.cleanup() }
|
||||||
|
deferredCleanup.clear()
|
||||||
loneIndices.clear()
|
loneIndices.clear()
|
||||||
// Notify in case worker is waiting (though it should be interrupted)
|
chapterPreloadsInFlight.clear()
|
||||||
lock.notifyAll()
|
lock.notifyAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the view this viewer uses.
|
|
||||||
*/
|
|
||||||
override fun getView(): View = pager
|
override fun getView(): View = pager
|
||||||
|
|
||||||
/**
|
/** Downloads [page] if needed, then re-queues it for decode once ready. */
|
||||||
* Start loading a page and set up listener to re-queue when ready.
|
|
||||||
* Called when decode worker encounters a page that isn't downloaded yet.
|
|
||||||
*/
|
|
||||||
private fun startPageLoad(page: ViewerReaderPage) {
|
private fun startPageLoad(page: ViewerReaderPage) {
|
||||||
val loader = page.page.chapter.pageLoader ?: run {
|
val loader = page.page.chapter.pageLoader ?: run {
|
||||||
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
|
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If page is already ready, just re-queue immediately
|
|
||||||
if (page.page.status == Page.State.Ready) {
|
if (page.page.status == Page.State.Ready) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (pageInCache(page) && !page.isDecoded) {
|
if (pageInCache(page) && !page.isDecoded) {
|
||||||
@@ -969,34 +1011,33 @@ open class WebGpuViewer(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transition to LOADING state
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (!pageInCache(page)) return
|
if (!pageInCache(page)) return
|
||||||
page.state = PageState.LOADING
|
page.state = PageState.LOADING
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the download
|
|
||||||
if (page.page.status == Page.State.Queue) {
|
if (page.page.status == Page.State.Queue) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
loader.loadPage(page.page)
|
loader.loadPage(page.page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up progress indicator and re-queue when ready
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val downloadProgressJob = launch {
|
val downloadProgressJob = launch {
|
||||||
page.page.progressFlow.collect { value ->
|
page.page.progressFlow.collect { value ->
|
||||||
// Still the placeholder? Evicted or decoded, and there is nothing to fill.
|
// Set under the lookup's lock, or an eviction's cleanup() lands between.
|
||||||
val progressPage = synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (!pageInCache(page)) return@collect
|
if (!pageInCache(page)) return@collect
|
||||||
page.imagePage as? ProgressPage ?: return@collect
|
(page.imagePage as? ProgressPage)?.progress = value / 100f
|
||||||
}
|
}
|
||||||
progressPage.progress = value / 100f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
page.page.statusFlow.takeWhile { state ->
|
page.page.statusFlow.takeWhile { state ->
|
||||||
|
// Evicted: stop watching, rather than holding the page until the download ends.
|
||||||
|
if (!synchronized(lock) { pageInCache(page) }) return@takeWhile false
|
||||||
|
|
||||||
when (state) {
|
when (state) {
|
||||||
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
|
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
|
||||||
is Page.State.Error -> {
|
is Page.State.Error -> {
|
||||||
@@ -1010,7 +1051,6 @@ open class WebGpuViewer(
|
|||||||
|
|
||||||
downloadProgressJob.cancel()
|
downloadProgressJob.cancel()
|
||||||
|
|
||||||
// Re-queue for decoding if ready
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (pageInCache(page) && page.state == PageState.LOADING) {
|
if (pageInCache(page) && page.state == PageState.LOADING) {
|
||||||
page.state = PageState.IDLE
|
page.state = PageState.IDLE
|
||||||
@@ -1030,7 +1070,6 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun decodeReaderPage(page: ViewerReaderPage) {
|
private suspend fun decodeReaderPage(page: ViewerReaderPage) {
|
||||||
// If page isn't downloaded yet, start loading
|
|
||||||
if (page.page.status != Page.State.Ready) {
|
if (page.page.status != Page.State.Ready) {
|
||||||
startPageLoad(page)
|
startPageLoad(page)
|
||||||
return
|
return
|
||||||
@@ -1042,7 +1081,7 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
stream.use { input ->
|
stream.use { input ->
|
||||||
// Check if still valid before decoding (not evicted and doesn't have decoded image yet)
|
// Not evicted, and not already decoded by a concurrent call.
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (!pageInCache(page) || page.isDecoded) {
|
if (!pageInCache(page) || page.isDecoded) {
|
||||||
if (pageInCache(page)) page.state = PageState.IDLE
|
if (pageInCache(page)) page.state = PageState.IDLE
|
||||||
@@ -1051,8 +1090,9 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Buffered to read the spread tag, then decoded from the buffer. On the preference,
|
// 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.
|
// not isDualPageMode(): WIDE is portrait-off, and a rotate never re-decodes. Never in
|
||||||
val bytes = if (config.dualPageView != ReaderPreferences.DualPageView.NEVER) {
|
// continuous, where nothing pairs - that mode reads the stream instead of holding it.
|
||||||
|
val bytes = if (!isContinuous && config.dualPageView != ReaderPreferences.DualPageView.NEVER) {
|
||||||
input.readBytes()
|
input.readBytes()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1070,6 +1110,22 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
GainmapInput(
|
||||||
|
pixels = it.pixels,
|
||||||
|
width = it.width,
|
||||||
|
height = it.height,
|
||||||
|
channels = it.channels,
|
||||||
|
gamma = it.gamma,
|
||||||
|
minContentBoost = it.minContentBoost,
|
||||||
|
maxContentBoost = it.maxContentBoost,
|
||||||
|
offsetSdr = it.offsetSdr,
|
||||||
|
offsetHdr = it.offsetHdr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val dec = ImageDecoder.new(bytes?.inputStream() ?: input)
|
val dec = ImageDecoder.new(bytes?.inputStream() ?: input)
|
||||||
|
|
||||||
val pageCount = dec.pages
|
val pageCount = dec.pages
|
||||||
@@ -1099,26 +1155,50 @@ open class WebGpuViewer(
|
|||||||
trimColors = trimColors,
|
trimColors = trimColors,
|
||||||
trimThreshold = 0.15f,
|
trimThreshold = 0.15f,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
|
hdr = firstFrame.isHdr,
|
||||||
|
hdrHeadroom = firstFrame.hdrHeadroom,
|
||||||
|
gainmap = firstFrame.gainmapInput(),
|
||||||
)
|
)
|
||||||
|
|
||||||
ImagePage.ImageSingle(firstImage)
|
ImagePage.ImageSingle(firstImage)
|
||||||
} else {
|
} else {
|
||||||
val frames = ArrayList<Pair<Image, Int>>(pageCount)
|
val frames = ArrayList<Pair<Image, Int>>(pageCount)
|
||||||
|
|
||||||
|
// Built frames hold uploaded textures, and ImageSingle owns the only teardown.
|
||||||
|
fun discardFrames() {
|
||||||
|
if (frames.isNotEmpty()) ImagePage.ImageSingle(frames).cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
val firstImage = Image(
|
val firstImage = Image(
|
||||||
firstFrame.image,
|
firstFrame.image,
|
||||||
firstFrame.width,
|
firstFrame.width,
|
||||||
firstFrame.height,
|
firstFrame.height,
|
||||||
createMipMaps = false,
|
createMipMaps = false,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
|
hdr = firstFrame.isHdr,
|
||||||
|
hdrHeadroom = firstFrame.hdrHeadroom,
|
||||||
|
gainmap = firstFrame.gainmapInput(),
|
||||||
)
|
)
|
||||||
|
|
||||||
frames.add(Pair(firstImage, firstFrame.duration))
|
frames.add(Pair(firstImage, firstFrame.duration))
|
||||||
|
|
||||||
repeat(pageCount - 1) {
|
try {
|
||||||
(page.imagePage as? ProgressPage)?.apply {
|
for (i in 1 until pageCount) {
|
||||||
progress = (it + 1).toFloat() / 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 frame = dec.decodeNext()
|
||||||
val image = Image(
|
val image = Image(
|
||||||
frame.image,
|
frame.image,
|
||||||
@@ -1126,9 +1206,16 @@ open class WebGpuViewer(
|
|||||||
frame.height,
|
frame.height,
|
||||||
createMipMaps = false,
|
createMipMaps = false,
|
||||||
backgroundColor = firstImage.backgroundColor,
|
backgroundColor = firstImage.backgroundColor,
|
||||||
|
hdr = frame.isHdr,
|
||||||
|
hdrHeadroom = frame.hdrHeadroom,
|
||||||
|
gainmap = frame.gainmapInput(),
|
||||||
)
|
)
|
||||||
frames.add(Pair(image, frame.duration))
|
frames.add(Pair(image, frame.duration))
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
discardFrames()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
ImagePage.ImageSingle(frames)
|
ImagePage.ImageSingle(frames)
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1226,7 @@ open class WebGpuViewer(
|
|||||||
page.imagePage = imagePage
|
page.imagePage = imagePage
|
||||||
noteIfLone(page)
|
noteIfLone(page)
|
||||||
page.state = PageState.IDLE
|
page.state = PageState.IDLE
|
||||||
oldImagePage.cleanup()
|
cleanupImage(oldImagePage)
|
||||||
// Fade up from the placeholder's colour, if that placeholder was on screen -
|
// 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.
|
// one that decoded out of view has nothing left to fade from.
|
||||||
if (oldImagePage.isOnScreen) imagePage.fadeIn()
|
if (oldImagePage.isOnScreen) imagePage.fadeIn()
|
||||||
@@ -1171,7 +1258,6 @@ open class WebGpuViewer(
|
|||||||
|
|
||||||
val image = page.image ?: return false
|
val image = page.image ?: return false
|
||||||
|
|
||||||
// if the untrimmed page is wide and the trimmed page is wide
|
|
||||||
val aspectRatio = min(
|
val aspectRatio = min(
|
||||||
page.trimWidth.toFloat() / page.trimHeight.toFloat(),
|
page.trimWidth.toFloat() / page.trimHeight.toFloat(),
|
||||||
image.width.toFloat() / image.height.toFloat(),
|
image.width.toFloat() / image.height.toFloat(),
|
||||||
@@ -1183,10 +1269,9 @@ open class WebGpuViewer(
|
|||||||
// Wide page: half the image width is wider than the screen aspect ratio
|
// Wide page: half the image width is wider than the screen aspect ratio
|
||||||
if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false
|
if (aspectRatio <= 2f * screenW.toFloat() / screenH) return false
|
||||||
|
|
||||||
// need to set parent for positioning to work
|
|
||||||
page.parent = pager.state
|
page.parent = pager.state
|
||||||
|
|
||||||
// Scale to fit half the image width to the full screen width
|
// Half the image width fills the screen width.
|
||||||
page.homeScale = screenW.toFloat() / (page.trimWidth / 2f)
|
page.homeScale = screenW.toFloat() / (page.trimWidth / 2f)
|
||||||
|
|
||||||
page.scale = page.homeScale
|
page.scale = page.homeScale
|
||||||
@@ -1245,9 +1330,6 @@ open class WebGpuViewer(
|
|||||||
page.y = page.homeY
|
page.y = page.homeY
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue a page for decoding. If prioritize=true, moves existing queued page to front.
|
|
||||||
*/
|
|
||||||
protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) {
|
protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val cachedPage = findInCache(pageKey(page)) ?: return
|
val cachedPage = findInCache(pageKey(page)) ?: return
|
||||||
@@ -1258,14 +1340,11 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun preloadPages(page: ViewerPage) {
|
protected fun preloadPages(page: ViewerPage) {
|
||||||
// Get the canonical page from cache to ensure we're working with current data
|
// page may be a stale copy - resolve the live cache entry.
|
||||||
val key = pageKey(page)
|
val key = pageKey(page)
|
||||||
val cachedPage = synchronized(lock) { findInCache(key) } ?: return
|
val cachedPage = synchronized(lock) { findInCache(key) } ?: return
|
||||||
|
|
||||||
// Priority order: current (highest), next1, next2, prev1, prev2 (lowest)
|
// prev, then next, then current+partner - the last prioritized call ends up highest.
|
||||||
// Add in reverse for LIFO, current page gets prioritized
|
|
||||||
|
|
||||||
// Add prev pages (lowest priority)
|
|
||||||
val prevPages = mutableListOf<ViewerPage>()
|
val prevPages = mutableListOf<ViewerPage>()
|
||||||
var p: ViewerPage? = cachedPage
|
var p: ViewerPage? = cachedPage
|
||||||
for (i in 0 until preloadBehind) {
|
for (i in 0 until preloadBehind) {
|
||||||
@@ -1274,7 +1353,6 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
prevPages.asReversed().forEach { preloadPage(it) }
|
prevPages.asReversed().forEach { preloadPage(it) }
|
||||||
|
|
||||||
// Add next pages (medium priority)
|
|
||||||
val nextPages = mutableListOf<ViewerPage>()
|
val nextPages = mutableListOf<ViewerPage>()
|
||||||
p = cachedPage
|
p = cachedPage
|
||||||
for (i in 0 until preloadAhead) {
|
for (i in 0 until preloadAhead) {
|
||||||
@@ -1283,8 +1361,6 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
nextPages.asReversed().forEach { preloadPage(it) }
|
nextPages.asReversed().forEach { preloadPage(it) }
|
||||||
|
|
||||||
// Add current spread last with priority flag (highest priority in LIFO)
|
|
||||||
// Also preload the paired page
|
|
||||||
cachedPage.next?.let { preloadPage(it, prioritize = true) }
|
cachedPage.next?.let { preloadPage(it, prioritize = true) }
|
||||||
preloadPage(cachedPage, prioritize = true)
|
preloadPage(cachedPage, prioritize = true)
|
||||||
}
|
}
|
||||||
@@ -1294,18 +1370,18 @@ open class WebGpuViewer(
|
|||||||
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
|
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
|
||||||
*/
|
*/
|
||||||
override fun setChapters(chapters: ViewerChapters) {
|
override fun setChapters(chapters: ViewerChapters) {
|
||||||
val pages = chapters.currChapter.pages ?: return
|
// Empty too: lastIndex would be -1, and the requested page is read from it.
|
||||||
|
val pages = chapters.currChapter.pages
|
||||||
|
if (pages.isNullOrEmpty()) return
|
||||||
|
|
||||||
this.viewerChapters = chapters
|
this.viewerChapters = chapters
|
||||||
|
|
||||||
val requestedIndex = min(chapters.currChapter.requestedPage, pages.lastIndex)
|
// Only when nothing shows yet - re-setting chapters must not move the page.
|
||||||
val requestedPage = pages[requestedIndex]
|
val page = currentPage ?: getPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)])
|
||||||
|
val anchor = getSpreadAnchor(page)
|
||||||
// Get the page and align to spread anchor if needed
|
currentPage = anchor
|
||||||
val page = currentPage ?: getPage(requestedPage)
|
progressPage(anchor)?.let { activity.onPageSelected(it.page) }
|
||||||
currentPage = getSpreadAnchor(page)
|
preloadPages(anchor)
|
||||||
(currentPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
|
|
||||||
preloadPages(currentPage!!)
|
|
||||||
|
|
||||||
pager.state.apply {
|
pager.state.apply {
|
||||||
onPageChange = onPageChange@{ delta ->
|
onPageChange = onPageChange@{ delta ->
|
||||||
@@ -1330,7 +1406,7 @@ open class WebGpuViewer(
|
|||||||
val settled = page
|
val settled = page
|
||||||
this@WebGpuViewer.scope.launch {
|
this@WebGpuViewer.scope.launch {
|
||||||
activity.hideMenu()
|
activity.hideMenu()
|
||||||
(settled as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
|
progressPage(settled)?.let { activity.onPageSelected(it.page) }
|
||||||
preloadPages(settled)
|
preloadPages(settled)
|
||||||
|
|
||||||
(settled as? ViewerTransitionPage)?.let { transitionPage ->
|
(settled as? ViewerTransitionPage)?.let { transitionPage ->
|
||||||
@@ -1352,7 +1428,6 @@ open class WebGpuViewer(
|
|||||||
override fun moveToPage(page: ReaderPage) {
|
override fun moveToPage(page: ReaderPage) {
|
||||||
// Pin first: resolving a target outside the cached window trims the cache.
|
// Pin first: resolving a target outside the cached window trims the cache.
|
||||||
pinnedFromPage = currentPage?.let { buildSpreadPage(it) }
|
pinnedFromPage = currentPage?.let { buildSpreadPage(it) }
|
||||||
// Get the page and align to spread anchor based on image position
|
|
||||||
moveToPage(getSpreadAnchor(getPage(page)))
|
moveToPage(getSpreadAnchor(getPage(page)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1361,9 +1436,10 @@ open class WebGpuViewer(
|
|||||||
// Before preloadPages below trims the cache - see [pinnedFromPage].
|
// Before preloadPages below trims the cache - see [pinnedFromPage].
|
||||||
val fromSpread = previousPage?.let { buildSpreadPage(it) }
|
val fromSpread = previousPage?.let { buildSpreadPage(it) }
|
||||||
pinnedFromPage = fromSpread
|
pinnedFromPage = fromSpread
|
||||||
|
synchronized(lock) { flushDeferredCleanup() }
|
||||||
|
|
||||||
currentPage = newPage
|
currentPage = newPage
|
||||||
(newPage as? ViewerReaderPage)?.let { activity.onPageSelected(it.page) }
|
progressPage(newPage)?.let { activity.onPageSelected(it.page) }
|
||||||
preloadPages(newPage)
|
preloadPages(newPage)
|
||||||
|
|
||||||
(newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage ->
|
(newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage ->
|
||||||
@@ -1417,29 +1493,21 @@ open class WebGpuViewer(
|
|||||||
pager.state.animatePageTurn(if (isReversed) direction else -direction)
|
pager.state.animatePageTurn(if (isReversed) direction else -direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the next page.
|
|
||||||
*/
|
|
||||||
fun moveToNext() {
|
fun moveToNext() {
|
||||||
moveRight()
|
moveRight()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the previous page.
|
|
||||||
*/
|
|
||||||
fun moveToPrevious() {
|
fun moveToPrevious() {
|
||||||
moveLeft()
|
moveLeft()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the page at the right.
|
|
||||||
*/
|
|
||||||
protected open fun moveRight() {
|
protected open fun moveRight() {
|
||||||
pager.state.getPage(0)?.let { page ->
|
pager.state.getPage(0)?.let { page ->
|
||||||
if (config.navigateToPan) {
|
if (config.navigateToPan) {
|
||||||
val minX = page.minX(page.scale)
|
val minX = page.minX(page.scale)
|
||||||
val maxX = page.maxX(page.scale)
|
val maxX = page.maxX(page.scale)
|
||||||
val currentX = page.animationJob?.let { page.animationTargetX } ?: page.x
|
// Where a running pan is headed, else where it sits.
|
||||||
|
val currentX = page.animationTargetX ?: page.x
|
||||||
|
|
||||||
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
|
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
|
||||||
val x = (currentX - c / page.scale).coerceIn(minX, maxX)
|
val x = (currentX - c / page.scale).coerceIn(minX, maxX)
|
||||||
@@ -1454,15 +1522,12 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the page at the left.
|
|
||||||
*/
|
|
||||||
protected open fun moveLeft() {
|
protected open fun moveLeft() {
|
||||||
pager.state.getPage(0)?.let { page ->
|
pager.state.getPage(0)?.let { page ->
|
||||||
if (config.navigateToPan) {
|
if (config.navigateToPan) {
|
||||||
val minX = page.minX(page.scale)
|
val minX = page.minX(page.scale)
|
||||||
val maxX = page.maxX(page.scale)
|
val maxX = page.maxX(page.scale)
|
||||||
val currentX = page.animationJob?.isActive?.let { page.animationTargetX } ?: page.x
|
val currentX = page.animationTargetX ?: page.x
|
||||||
|
|
||||||
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
|
val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1
|
||||||
val x = (currentX + c / page.scale).coerceIn(minX, maxX)
|
val x = (currentX + c / page.scale).coerceIn(minX, maxX)
|
||||||
@@ -1477,49 +1542,32 @@ open class WebGpuViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Target anchor page one spread past [from], in [direction] (positive = forward). */
|
||||||
* Get the target page when navigating by spreads from the given page.
|
|
||||||
* @param from Starting page
|
|
||||||
* @param direction Positive = forward in page numbers, negative = backward
|
|
||||||
* @return Target page or null if navigation not possible
|
|
||||||
*/
|
|
||||||
private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? {
|
private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? {
|
||||||
var page = getSpreadAnchor(from)
|
var page = getSpreadAnchor(from)
|
||||||
|
|
||||||
page = if (direction > 0) {
|
page = if (direction > 0) {
|
||||||
// Going forward (next spread)
|
if (page is ViewerReaderPage && spreadPartner(page) != null) {
|
||||||
if (page is ViewerReaderPage && canFormSpread(page)) {
|
|
||||||
page.next?.next ?: return null
|
page.next?.next ?: return null
|
||||||
} else {
|
} else {
|
||||||
page.next ?: return null
|
page.next ?: return null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Going backward (prev spread)
|
|
||||||
page.prev ?: return null
|
page.prev ?: return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSpreadAnchor(page)
|
return getSpreadAnchor(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate by spreads from current page.
|
|
||||||
* @param direction Positive = forward in page numbers, negative = backward
|
|
||||||
*/
|
|
||||||
private fun navigateSpread(direction: Int) {
|
private fun navigateSpread(direction: Int) {
|
||||||
val target = currentPage?.let { nextPage(it, direction) } ?: return
|
val target = currentPage?.let { nextPage(it, direction) } ?: return
|
||||||
moveToPage(target)
|
moveToPage(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the page at the top (or previous).
|
|
||||||
*/
|
|
||||||
protected fun moveUp() {
|
protected fun moveUp() {
|
||||||
moveToPrevious()
|
moveToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the page at the bottom (or next).
|
|
||||||
*/
|
|
||||||
protected fun moveDown() {
|
protected fun moveDown() {
|
||||||
moveToNext()
|
moveToNext()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
|||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
class WebGpuViewerContinuous(activity: ReaderActivity) :
|
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
|
||||||
WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) {
|
WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) {
|
||||||
|
|
||||||
override val isContinuous: Boolean = true
|
override val isContinuous: Boolean = true
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ firebase-bom = "34.19.0"
|
|||||||
firebase-crashlytics = "3.0.8"
|
firebase-crashlytics = "3.0.8"
|
||||||
flexibleAdapter = "c8013533"
|
flexibleAdapter = "c8013533"
|
||||||
google-services = "4.5.0"
|
google-services = "4.5.0"
|
||||||
image-decoder = "10"
|
image-decoder = "13"
|
||||||
injekt = "91edab2317"
|
injekt = "91edab2317"
|
||||||
jsoup = "1.23.2"
|
jsoup = "1.23.2"
|
||||||
junit = "6.1.3"
|
junit = "6.1.3"
|
||||||
@@ -75,7 +75,7 @@ tapmoc = "0.4.2"
|
|||||||
unifile = "08f224c8f9"
|
unifile = "08f224c8f9"
|
||||||
valkyrie = "0.5.2"
|
valkyrie = "0.5.2"
|
||||||
voyager = "2.2.21-1.10.3"
|
voyager = "2.2.21-1.10.3"
|
||||||
webgpuviewer = "40"
|
webgpuviewer = "41"
|
||||||
xmlutil = "1.0.2"
|
xmlutil = "1.0.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|||||||
@@ -524,6 +524,7 @@
|
|||||||
<string name="dual_page_view_always">Always</string>
|
<string name="dual_page_view_always">Always</string>
|
||||||
<string name="dual_page_view_wide">When wide</string>
|
<string name="dual_page_view_wide">When wide</string>
|
||||||
<string name="pref_continuous_minwidth">Min width</string>
|
<string name="pref_continuous_minwidth">Min width</string>
|
||||||
|
<string name="pref_continuous_gap">Gap</string>
|
||||||
|
|
||||||
<!-- Downloads section -->
|
<!-- Downloads section -->
|
||||||
<string name="pref_category_delete_chapters">Delete chapters</string>
|
<string name="pref_category_delete_chapters">Delete chapters</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user