Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions app/src/main/java/io/theficos/ereader/ui/reader/ReaderScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
Expand Down Expand Up @@ -137,6 +139,21 @@ fun ReaderScreen(viewModel: ReaderViewModel, onClose: () -> Unit) {
onExit = viewModel::clearPendingResize,
)

// Last measured size of the reader content. Every change is reported to the
// view model, which re-anchors the reading position across it whatever caused
// it β€” see ReaderViewModel.onViewportChanged. This is keyed on the size so a
// resize arriving in several steps (an inset animation, say) restarts the wait
// instead of completing mid-flight. Readium's onPageChanged re-anchors as it
// re-paginates; this has the last word, once the size has stopped moving and
// the WebView's page grid is final.
var viewport by remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(viewport) {
if (viewport != IntSize.Zero) {
delay(RESIZE_SETTLE_MS)
viewModel.completeViewportResize()
}
}

// The app is edge-to-edge (MainActivity). Immersive reading uses that full
// bleed: content draws behind the (hidden) bars and the chrome self-insets.
// With immersive off, the reader behaves like any normal screen β€” inset the
Expand All @@ -161,7 +178,11 @@ fun ReaderScreen(viewModel: ReaderViewModel, onClose: () -> Unit) {
onPrev = viewModel::pageBackward,
onNext = viewModel::pageForward,
onToggleChrome = viewModel::toggleChrome,
onPageLoaded = viewModel::completeViewportResize,
onPageLoaded = viewModel::reanchorViewport,
onViewportChanged = { size ->
viewport = size
viewModel.onViewportChanged(size.width, size.height)
},
)

ReaderTopBar(
Expand Down Expand Up @@ -228,14 +249,19 @@ private fun ReaderContent(
onNext: () -> Unit,
onToggleChrome: () -> Unit,
onPageLoaded: () -> Unit,
onViewportChanged: (IntSize) -> Unit,
) {
val activity = LocalContext.current as FragmentActivity
val containerId = rememberSaveable { View.generateViewId() }
val tag = "reader-${publication.metadata.identifier ?: containerId}"
var fragment by remember { mutableStateOf<EpubNavigatorFragment?>(null) }

AndroidView(
modifier = Modifier.fillMaxSize(),
// This is the node whose height decides how much text fits in a Readium column, so
// it β€” not the window, and not the insets β€” is the authoritative viewport.
modifier = Modifier
.fillMaxSize()
.onSizeChanged(onViewportChanged),
factory = { ctx ->
ReaderTapDispatcher(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
Expand Down Expand Up @@ -280,6 +306,24 @@ private fun ReaderContent(
initialLocator = initialLocator,
initialPreferences = preferences.toEpubPreferences(),
paginationListener = paginationListener,
configuration = EpubNavigatorFragment.Configuration(
// Quire owns the reader's insets, so Readium must not also apply them.
//
// Left at its default (on), Readium pads its own page container by the
// system-bar insets whenever they are dispatched to its view. In full-screen
// reading the bars are hidden and the reader is deliberately full-bleed, so
// that padding is for bars that aren't there β€” and it arrived late: the page
// rendered edge to edge, then the first time the window regained focus the
// WebView finally re-measured against it and lost ~350px of height. That
// re-paginated the chapter under the reader, and because the resize happened
// inside Readium's own view tree, nothing here saw it coming: the post-
// re-pagination locator was published and written over the saved position, so
// the reader came back to the wrong page and stayed there (issue #95).
//
// With full-screen reading off it was simply double-inset: the whole reader
// subtree is already padded by WindowInsets.systemBars in ReaderScreen.
shouldApplyInsetsPadding = false,
),
)
val nav = (fm.fragmentFactory.instantiate(
activity.classLoader,
Expand Down Expand Up @@ -310,6 +354,13 @@ private fun ReaderContent(
}
}

/**
* How long to wait for Readium to re-paginate after the viewport changed before honouring the
* anchor anyway. onPageChanged normally gets there first; this only has to cover a resize that
* didn't re-paginate at all, so that locator publishing is never left suppressed.
*/
private const val RESIZE_SETTLE_MS = 600L

/**
* Drives immersive full-screen: binds the OS status + navigation bars to the reader
* chrome. Every window mutation is snapshotted once and reverted on exit so the rest of
Expand Down
166 changes: 156 additions & 10 deletions app/src/main/java/io/theficos/ereader/ui/reader/ReaderViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import io.theficos.ereader.reader.EpubAsset
import io.theficos.ereader.reader.ProgressTracker
import io.theficos.ereader.reader.ReaderPreferences
import io.theficos.ereader.reader.ReaderPreferencesStore
import io.theficos.ereader.reader.PAGE_START_ANCHOR_JS
import io.theficos.ereader.reader.ReadiumFactory
import io.theficos.ereader.reader.locatorAtPercent
import io.theficos.ereader.reader.parsePageStartAnchor
import io.theficos.ereader.reader.resizeAnchor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -34,6 +37,11 @@ class ReaderViewModel(
private val progress: ProgressRepository,
private val readium: ReadiumFactory,
private val preferencesStore: ReaderPreferencesStore,
// How the reader asks Readium whereabouts in the DOM the current page starts. A seam so
// the re-anchor can be tested without a live WebView; see ReaderViewportResizeTest.
private val readDomAnchor: suspend (EpubNavigatorFragment?) -> Locator? =
::readPageStartAnchor,
private val nowMs: () -> Long = System::currentTimeMillis,
) : ViewModel() {

private val _state = MutableStateFlow<ReaderUiState>(ReaderUiState.Loading)
Expand Down Expand Up @@ -87,6 +95,8 @@ class ReaderViewModel(
}

fun goTo(locator: Locator) {
clearPendingResize()
invalidateDomAnchor()
viewModelScope.launch { navigator?.go(locator, false) }
}

Expand Down Expand Up @@ -128,28 +138,129 @@ class ReaderViewModel(
if (suppressLocatorPublishing) return
_currentLocator.value = locator
_locatorUpdates.tryEmit(locator)
refreshDomAnchor()
}

/**
* Keeps [domAnchor] tracking the page on screen.
*
* Asking Readium costs a round trip into the WebView's JavaScript, which is far too slow to
* do at the moment a rotation starts β€” the answer would come back describing the page after
* re-pagination, which is the one thing it must not be. So it is kept warm instead, refreshed
* whenever Readium reports a settled position, and read synchronously when the resize arms.
*
* Both guards here exist to keep a rotation from rewriting the anchor, which is the failure
* that made rotation drift compound. Which element anchors a page depends on the page's
* shape: a tall portrait column starts several paragraphs where a short landscape one starts
* a single paragraph, so reading the anchor in the orientation the reader is only passing
* through replaces it with an earlier one, and the return leg dutifully honours that. Turning
* a page moves the reader; rotating the device does not.
*/
private fun refreshDomAnchor() {
if (nowMs() < anchorPinnedUntil) return
viewModelScope.launch {
val nav = navigator
val dom = runCatching { readDomAnchor(nav) }.getOrNull()
// A resize may have armed while that round trip was in flight, in which case the
// answer describes the re-paginated page. Drop it and keep the pre-resize anchor.
if (!suppressLocatorPublishing && nowMs() >= anchorPinnedUntil) domAnchor = dom
}
}

private var pendingRotationAnchor: Locator? = null
private var suppressLocatorPublishing: Boolean = false
private var viewportSize: Pair<Int, Int>? = null
private var domAnchor: Locator? = null
private var anchorPinnedUntil: Long = 0L

/**
* Reports the reader viewport's measured size, on every layout pass.
*
* Any change in that size re-paginates Readium's WebView, after which Readium reports
* whatever text happened to land on screen. Nothing marks that locator as junk, so left
* alone it is published to the HUD and written over the reader's saved position β€” the
* reader comes back to the wrong page and stays there.
*
* Everything else here arms the re-anchor by naming a cause: [beginViewportResize] is
* called from `MainActivity.onConfigurationChanged` for rotation, and from the
* full-screen-reading toggle. Issue #95 was a resize nobody had named, so this arms on
* the effect instead β€” the measured size β€” and covers causes we haven't thought of.
* (It only sees resizes of the Compose node; one that happens further down, inside
* Readium's own view tree, is invisible here. See `shouldApplyInsetsPadding` in
* ReaderScreen for the one that bit us.)
*
* The first report just establishes the baseline; the WebView is created at that size, so
* there is nothing to re-anchor. Zero sizes (pre-layout, or a detached view) are ignored
* so they never read as a resize in either direction.
*/
fun onViewportChanged(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
val previous = viewportSize
viewportSize = width to height
if (previous == null || previous == viewportSize) return
beginViewportResize()
}

/**
* Forgets where the page on screen begins, because the reader is being sent elsewhere.
*
* [domAnchor] describes the page being left. Until Readium reports the one being arrived at,
* a resize landing in between would otherwise fold that stale element into the new locator
* and send the reader straight back to the page they just jumped away from.
*/
private fun invalidateDomAnchor() {
domAnchor = null
}

// Called from MainActivity.onBeforeReaderConfigChange β€” runs BEFORE the
// Activity dispatches the configuration change down to fragments. Snapshots
// the current locator into pendingRotationAnchor and gates publishLocator so
// Readium's post-resize drifted emissions cannot overwrite the anchor.
// Called from MainActivity.onBeforeReaderConfigChange β€” runs BEFORE the Activity dispatches
// the configuration change down to fragments. Snapshots where the reader is into
// pendingRotationAnchor and gates publishLocator so Readium's post-resize drifted emissions
// cannot overwrite it.
fun beginViewportResize() {
val anchor = _currentLocator.value ?: return
pendingRotationAnchor = anchor
// A single rotation arms this several times over β€” MainActivity as the configuration
// change is dispatched, then onViewportChanged as the measured size follows. Only the
// first of those still sees the page the reader is leaving, so it wins.
if (pendingRotationAnchor != null) return
val live = _currentLocator.value ?: return
pendingRotationAnchor = resizeAnchor(live, domAnchor)
suppressLocatorPublishing = true
}

// Called from the Readium PaginationListener when re-pagination completes
// after a viewport resize. No-op if no resize is pending (so it's safe to
// call on every onPageLoaded). Re-anchors via navigator.go(anchor, false),
// re-seeds the current-locator flow, and re-enables publishing.
/**
* Puts the reader back on the anchored page, and stays armed. Called from the Readium
* pagination listener on every re-pagination while a resize is in flight; a no-op when no
* resize is pending, so it is safe on every page turn.
*
* Re-pagination arrives in steps, and anchoring on the first one is not enough: wired that
* way, and with everything else here unchanged, rotation still landed on the wrong page and
* did so inconsistently, the same start position coming back two different ways. Readium
* scrolls to an anchor by snapping the element's offset to a page boundary, using a page
* width it caches in JavaScript and only recomputes when it is told the viewport moved, so
* an early re-anchor is measuring against a grid that is still the old one. Re-anchoring on
* every step and once more when the size stops changing ([completeViewportResize]) gives the
* settled layout the last word.
*
* Repeating this is only safe because the anchor is a DOM element: it lands in the same place
* however many times it is used. The progression fraction this used to carry was consumed a
* little by every application, which is why it could only ever be applied once.
*/
fun reanchorViewport() {
val anchor = pendingRotationAnchor ?: return
viewModelScope.launch { navigator?.go(anchor, false) }
}

// Called when the viewport has stopped changing (and from the immersive transition's own
// settle timer). No-op if no resize is pending. Re-anchors one last time, now that the
// layout is final, re-seeds the current-locator flow and re-enables publishing.
fun completeViewportResize() {
val anchor = pendingRotationAnchor ?: return
pendingRotationAnchor = null
// Readium reports the restored position of its own accord a moment after the jump β€”
// measured at around half a second. That report is the resize finishing, not the reader
// moving, so it must not be allowed to re-read the anchor from the page it just landed
// on. Hold the anchor over that window; the next page the reader actually turns to
// refreshes it normally.
anchorPinnedUntil = nowMs() + ANCHOR_PIN_MS
viewModelScope.launch {
navigator?.go(anchor, false)
_currentLocator.value = anchor
Expand All @@ -174,6 +285,10 @@ class ReaderViewModel(
fun seek(percent: Double) {
val target = previewLocator(percent) ?: return
val nav = navigator ?: return
// An explicit jump supersedes any armed re-anchor: the anchor predates the seek, so
// honouring it afterwards would yank the reader back out of the page they just chose.
clearPendingResize()
invalidateDomAnchor()
// Surface the target on the HUD synchronously, before the suspending nav.go()
// call dispatches. This avoids a one-frame window where the slider thumb
// would snap back to the pre-seek liveLocator after the UI clears its drag
Expand All @@ -196,6 +311,13 @@ class ReaderViewModel(
}
}

/**
* How long the reading anchor is held after a viewport resize completes, covering Readium's own
* delayed report of where it landed. Long enough for that report (about half a second in
* practice), short enough that a page the reader turns to just after a rotation still registers.
*/
private const val ANCHOR_PIN_MS = 1_500L

sealed interface ReaderUiState {
data object Loading : ReaderUiState
data class Error(val message: String) : ReaderUiState
Expand All @@ -206,3 +328,27 @@ sealed interface ReaderUiState {
val savedProgress: Progress?,
) : ReaderUiState
}

/**
* Asks the navigator where the page on screen begins in the DOM.
*
* Runs [PAGE_START_ANCHOR_JS] in the current resource's web view and stamps the answer with that
* resource's href, so a stale anchor can be rejected later if the reader has moved on to another
* chapter. Falls back to Readium's own `firstVisibleElementLocator()` whenever the script
* declines to answer: a page with nothing starting on it, or a layout the script bows out of.
* That fallback anchors slightly earlier than the reader actually is β€” the very thing the script
* exists to improve on β€” but it is still an exact DOM anchor, so it costs at most a one-off
* shift rather than the compounding walk the progression fraction caused.
*/
private suspend fun readPageStartAnchor(navigator: EpubNavigatorFragment?): Locator? {
val nav = navigator ?: return null
val current = nav.currentLocator.value
val fromScript = runCatching {
parsePageStartAnchor(
json = nav.evaluateJavascript(PAGE_START_ANCHOR_JS),
href = current.href,
mediaType = current.mediaType,
)
}.getOrNull()
return fromScript ?: runCatching { nav.firstVisibleElementLocator() }.getOrNull()
}
Loading
Loading