Kapow

View the Project on GitHub sanchezpaco/kapow

Reading modes

The reader chooses a surface from the current ReadingPosture (see foldable.md). Each surface is a Compose strategy fed the same posture-agnostic ReadingPosition.

Full page (single)

Default on UnfoldedSingle and CompactSingle.

Ambient backdrop

When opened from the library the backdrop starts from the cover’s persisted ambient colour (ReaderScreen(initialAmbient)), then follows each page.

Each decoded page carries an ambient color (Palette vibrant → muted → dominant fallback), cached with the page bitmap. The reader paints a radial gradient from a darkened ambient glow at the page center out to pure black at the edges, so the page appears to emit light into the letterbox. The current page’s ambient is animated (animateColorAsState), so turning a page smoothly shifts the glow.

Two-page spread

Default on UnfoldedSpread.

Book mode (tabletop)

Active in Tabletop posture (half-folded, horizontal hinge).

Guided View

Available on CompactSingle, and on demand elsewhere. Full detail in guided-view.md.

Enlarged speech bubbles

An alternative to Guided View for small lettering that keeps the whole page (and its art) on screen: the ChatBubble button in the HUD (ReaderUiState.bubblesEnlarged, hidden while Guided View is on) redraws every detected speech bubble and caption scaled up in place (1.3× by default, a stepper in the view-mode panel sets 1.1–2×, persisted), as an overlay on the regular page surfaces (ZoomablePage, so it works on single pages, the spread and tabletop and follows pinch/double-tap zoom). It is a static render enhancement — no page zoom, no bubble-to-bubble navigation, nothing to order — so it deliberately sidesteps panel segmentation and reading-order errors. Full detail in speech-bubbles.md.

Autoplay

A Timer switch at the bottom of the view-mode (eye) panel reads the comic on its own, at a pace you set in seconds per page. Turning it on reveals a stepper — a − circle, the value, a + circle, with Per page in the empty left half of the row (Paused while it is) — that walks AUTOPLAY_SECONDS_RANGE (3–120 s) in AUTOPLAY_SECONDS_STEP (1 s) steps, disabled at the bounds. A second is the right grain to tune a pace with and a poor way to cross the range, so a held button repeats and then coarsens: one step on press, then after AUTOPLAY_REPEAT_DELAY_MS (400 ms) one step every AUTOPLAY_REPEAT_INTERVAL_MS (120 ms), and after AUTOPLAY_COARSE_AFTER_MS (1.2 s) of repeating the increment becomes AUTOPLAY_COARSE_STEP (5 s) at the same cadence. It is the step that coarsens, not the value: Autoplay.coarseStepped moves to the next multiple of 5 in the direction of travel, so the number stays readable while it climbs (20, 25, 30…), a release always lands round, and a follow-up tap still nudges by exactly one. A light haptic marks entering the coarse phase and hitting a bound. The repeat is driven off the button’s own MutableInteractionSource (a PressInteraction.Press starts it, collectLatest ends it on release or cancel) so the ripple, the disabled state and the accessibility semantics stay IconButton’s; the trailing click after a hold is swallowed so a hold does not add a stray step. The bubble-scale stepper shares the step button but not the hold — 0.1× over 1.1–2× needs no shortcut.

Unlike enlarged bubbles, autoplay is offered in all three view modes, because every one of them has something to advance. N is always seconds per page:

Holding, and stopping

Autoplay has exactly two states on screen, and the difference between them is which control you used.

A finger is a dead-man switch. Touching the page anywhere holds autoplay; lifting continues it. The dwell freezes where it is and does not reset (AutoplayDwell.ticked returns itself while held), so a two-second glance does not cost you a whole page. Manual navigation is not an interruption to be punished: a tap zone, a swipe, a volume key, a guided advance, a pinch, a double-tap and a scrubber jump all happen normally, and because the dwell is keyed on the page and stop you land on, autoplay resumes from a full dwell wherever you ended up. A centre tap raises the chrome, and a visible chrome counts as held too — that is what lets you change the pace mid-run and watch the number change without the page turning under the panel.

The observer is one Modifier.pointerInput on the reader Box, in the chain only while autoplay is counting: it counts pointers down on PointerEventPass.Initial and up on PointerEventPass.Final and never consumes. That is deliberate and load-bearing — ZoomablePage still consumes only with two fingers down or already zoomed, PanSlop is untouched, and the pager swipe and TapZones see a byte-identical event stream. The strip does not get the observer: creepDown takes its scroll at MutatePriority.Default, so a drag pre-empts it at MutatePriority.UserInput and the loop waits for isScrollInProgress to go quiet before creeping on from wherever the finger left the page. That already is the dead-man switch, and a second one would only fight it.

Turning it off is always deliberate: the panel switch, ON_STOP (backgrounding latches it off — you do not come back to a page mid-turn), and the end of the comic, where the tick that would advance past the last page switches autoplay off and raises the end-of-comic card instead. Off is deliberately not reachable from the reader surface: a hidden long-press on the one visible control is exactly how the pill went wrong the first time.

The pill

The pill is the running indicator and the playback transport: a 40 dp circle that is a sibling of TopChrome but outside SlidingChrome, so it survives the chrome auto-hiding — once the HUD is gone it is the only thing on screen that says the page is about to turn, and the only way to interrupt it. It is declared after the night-tint layer, like the chrome, so the amber scrim never washes it.

It sits at the bottom-end corner (navigationBarsPadding(), 20 dp in, 84 dp up) and never moves — the 84 dp is a constant, not derived from whether the chrome is up, so it is the same pixel either way. Top-start was tried first and was wrong three ways: unreachable one-handed on the 475 dp cover screen, sitting on the page’s reading entry point, and landing on the page half rather than the controls half in Tabletop. At 84 dp it shares a band with the thumbnail scrubber, which yields rather than moves: ThumbnailScrubber takes an endInset of the pill plus 12 dp while autoplay is on. A near miss lands in the “next page” tap zone, which is harmless under the dead-man rule — autoplay just carries on from wherever you land.

The ground is opaque (PanelColor, the same near-black the HudPanel uses), clipped to a circle, with the ring drawn inside that clip so a round cap never spills past the rim. The first version used a White 0.12 ground with a White 0.16 ring and a white glyph, and over a cream page every one of them disappeared — the only surviving mark was the red arc, which itself died over red art. This screen already learned that lesson once when the HUD moved to HudPanel because a solid panel reads over any page. It is PanelColor and not palette.raised because the reader keeps black chrome on every theme, Paper included (see settings.md); there is no theme branch and no scrim, which would otherwise leave a permanent dark smudge in the corner of every autoplayed page.

Three states share that one disc:

  arc glyph motion
Running sweeps, colorScheme.primary Pause the arc sweeps
Held frozen in place, crossfades to White 0.45 over 120 ms Pause none
Paused frozen in place, stays colorScheme.primary PlayArrow glyph and arc pulse 1.0 → 0.55 → 1.0, 1600 ms

Held and Paused differ on three axes at once — arc colour, glyph and motion — so they cannot be read for each other. The pulse is applied to the glyph and the arc only, never the disc: dropping the ground’s opacity at the trough would undo the legibility fix twice a second. Resuming rebases the dwell to a full interval. The ring is a White 0.16 track with a colorScheme.primary arc sweeping from −90°, its progress read from an Animatable inside the Canvas draw, so a running countdown redraws 40 dp and recomposes nothing (performance.md records what compositing the reader’s scrolling content once cost). In the strip the pill draws the track ring and nothing else, ever: there is no discrete next turn to promise, and a full accent ring would read as a countdown that had already finished. TalkBack reads “Pause autoplay” / “Resume autoplay” with a Playing / Held / Paused state.

The switch in the eye panel is the mode — the rare, deliberate on/off — and the pill is the transport. Turning the switch off while paused clears the pause; turning it on always enters Running. While paused, the stepper’s caption slot doubles as the state line: Per page becomes Paused in the accent colour. One word, no new row, no new control.

Where the interval comes from

The interval is global and persisted (autoplay_seconds_per_page in the reader DataStore, beside bubble_scale) — not per comic and not in app settings. Until you set one it is seeded from what reading stats already know about you: ReadingPace.secondsPerPage for this comic’s series in the mode it opened in, clamped into the range, falling back to ReadingPace.FALLBACK_SECONDS_PER_PAGE when there is no usable history. ReaderViewModel reaches the stats repository through StatsEntryPoint, the same EntryPointAccessors pattern it already uses for the DAOs, since it is built by a plain factory rather than by Hilt.

Autoplay itself is never persisted and never on at open: it is session state, switched off again every time a comic is opened. While it is on the screen is kept awake regardless of the keep-screen-on setting, and the setting itself is never written.

Autoplayed sessions do not define your pace. A comic left running at 8 s a page would otherwise rewrite the median that seeds this very feature, and under a dead-man switch far more pages reach reading_session that way. The first autoplay-driven page event marks the session draft (SessionRecorder.onAutoplay → an autoplayed column on reading_session, Room v19 → v20), and ReadingPace.contributing drops those sessions. They still count as time and pages read on the stats screen — you were reading — they just do not set the pace.

Split wide pages

Some PDFs and scans store two comic pages side by side in one landscape file page. The per-comic setting “Split wide pages” (ComicSettings.splitWidePages, in the comic settings screen and in the reader’s gear menu) cuts every landscape page in half and presents each half as its own page.

Suggesting the split

Opening a comic with the setting off probes every page’s aspect (the same pageAspect the split uses) and, when at least 80 % of the pages after the cover are wider than tall (SplitSuggestion.shouldSuggest, at least two such pages), shows a snackbar once: “This comic looks like it has two pages per image · Split”. The action turns the setting on; accepting or dismissing records ComicSettings.splitSuggested so the comic never asks again. The check is purely geometric, so a landscape-format comic gets the same suggestion — that is why it suggests rather than splits on its own. On RAR files the probe waits for the archive extraction, so the snackbar can appear a few seconds after the first page.

Choosing a view mode

The reader has three layouts — paged, Guided View and the vertical strip — and they are mutually exclusive, so ReaderViewMode (pure, in reader/domain) picks one from the two flags that persist it and the HUD offers them as a single choice behind one control rather than as separate toggles that hide each other. Enlarged bubbles are not a fourth mode: they are an overlay that works over the paged layouts and over the strip, and only Guided View turns them off (ReaderViewMode.allowsBubbles), so they sit in the same menu as a toggle.

The eye button opens the view-mode panel (see Shared reader chrome). Its first three rows are the exclusive choice: the selected one is drawn in the accent colour with a trailing check, and picking one closes the panel, because choosing a layout is a one-shot decision. Below a hairline, “Enlarged bubbles” is a Switch row — a state you leave on, not a destination — and it disappears entirely in Guided View. “Page fit” sits above it in the same block, visible only in Pages mode (see Page fit below). Turning it on reveals the bubble-scale stepper underneath it: − and + buttons around the current value, stepping by BUBBLE_SCALE_STEP (0.1) inside BUBBLE_SCALE_RANGE (1.1–2×), clamped at both ends. The stepper replaced a floating slider that used to sit under the HUD buttons over the art: the setting now lives next to the switch that enables it, and nothing permanently covers the page. Below one more hairline, “Autoplay” repeats that shape — a Switch row that reveals its own seconds-per-page stepper — but is offered in every mode and does not close the panel when you flip it (see Autoplay above). Its hairline is always drawn; the bubbles block above it is the one that disappears in Guided View, so the panel never shows two rules in a row.

Continuous vertical scroll

A third top-level mode, chosen per comic (ComicSettings.verticalScroll, HUD toggle in the settings menu). It takes precedence over Guided View and the two-page spread and hides their toggles, because both are page-based and cannot coexist with a strip. Whole pages are stacked in one LazyColumn, fit to width, with no page-turn animation: scroll instead of paginate. Nothing in its path detects anything, so unlike Guided View it cannot misread a page.

The strip asks PageLoader.aspects() for every page’s aspect ratio before composing anything (ComicSource.pageAspect reads image headers only, the same probe the split suggestion already runs at open). Each item is then a fillMaxWidth().aspectRatio(aspect) box, so the list has its full scroll extent from the first frame and a page decoding never changes the height of anything above it. Margin cropping can make the decoded page slightly narrower than the file, which ContentScale.Fit letterboxes against the black background rather than resizing the item.

Zoom scales the whole strip, not one page. That is what removes the gesture conflict the mode looked like it had: the vertical drag is always the scroll, the horizontal drag is always the pan, and a two-finger pinch competes with neither. Pinch and double-tap zoom up to 4x, horizontal drag pans within the scaled bounds, and vertical drag keeps scrolling. Pages are decoded at 2160 px wide against a much narrower screen, so there is real resolution behind the zoom.

The scale is applied through a graphicsLayer only while actually zoomed. Wrapping a scrolling LazyColumn in a composited layer forces the whole viewport to re-rasterise every frame instead of letting Compose move already-rasterised items: measured, that alone took p99 from 29 ms to 700 ms and janky frames from 0.3 % to 6.8 %. At scale 1 the modifier is not in the chain at all.

A tap anywhere toggles the chrome, and the chrome hides again as soon as the strip starts scrolling — in the paged reader the next tap dismisses it, but in a strip you never tap, so the HUD would otherwise sit over the artwork for as long as you kept reading. That is gated on a DragInteraction.Start from the list’s interactionSource, not on isScrollInProgress: autoplay’s own creep also sets a scroll in progress, and hiding the chrome on it would close the panel holding the stepper the instant you pressed the switch. The thumbnail scrubber and volume keys still work, jumping and stepping by item. Enlarged bubbles work here too — they are a render overlay on a page, with nothing page-turn-specific about them, so the strip draws the same BubbleLayer the paged surfaces do. The strip is what exposed that the bubble layout pass and the detection decoding both ran on the main thread; see performance.md, “When gfxinfo says the reader is fine and it is not”. Reading position stays the first visible page, so it maps to the same ReadingPosition.pageIndex every other surface uses and survives a mode switch.

The strip does not stop at the end of an issue

When the comic belongs to a series, the strip keeps going into the next issue — the next issue of the series, whatever shelf or reading list the comic was opened from, so a list never reads as a playlist. The item list grows rather than being replaced (StripChain.items, pure and unit-tested): pages of issue n, a boundary band, pages of issue n+1, and so on. Appending only at the end means no index ever shifts under the reader, so crossing into a new issue costs no scroll correction and no jump.

The next issue opens when the reader comes within three pages of the end of the last one, so by the time the boundary is on screen its pages are already decodable. The boundary is deliberately visible — a black band naming the issue just finished and the one starting — because a seamless join loses the reader’s place and makes the next issue’s cover and credits appear without context.

Everything that reads as “the current comic” follows the issue under the top of the viewport: reading progress (so an issue is marked completed the moment the reader scrolls past it), the page counter, the thumbnail scrubber and the next-issue action in the end-of-comic overlay. Each PageLoader behind the reader has its caches dropped (PageLoader.release) once the reader is past its issue, which is what keeps a long chain from multiplying the page cache; the sources stay open so scrolling back only costs a re-decode.

Two details that are easy to get wrong and are covered here:

Measured on the emulator over a long fast scroll in both directions (330 frames): 0.91 % janky, p99 32 ms, 0 slow bitmap uploads, 0 missed vsync, and 601 frames scrolling across two issue boundaries hold 0.33 % janky, p99 29 ms, again with zero slow bitmap uploads. Slow bitmap uploads is the number that matters — a strip keeps more pages resident than the pager, and it was thrashing the HWUI texture cache that caused the original 78 %-jank bug (performance.md).

Sharing a page

The gear panel’s action zone carries “Share this page” (labelled “Share this panel” in Guided View) above “Report a visual glitch”. It hands the page to the Android share sheet as one full-resolution JPEG (quality 90) and closes the panel; there is no confirmation dialog, because the chooser is the confirmation and the image never leaves the device until a target is picked.

The image is what the reader is showing:

Posture and mode decide the framing (sharedPages, pure and unit-tested): a single page on every posture but the spread; in UnfoldedSpread both pages joined into one image, scaled to a common height and placed in the order they are drawn (joinedSlots, so right-to-left swaps the sides); in Guided View the current panel crop (panelCrop of the stop rect GuidedReader reports through onGuidedStop), which is why the row is relabelled there; in the strip the page under the top of the viewport, the one the counter names. Split halves and PDF pages are ordinary pages here.

SharePage (feature/reader/ui) composes every page into one canvas on Dispatchers.IO and writes a single JPEG into cache/share/, wiping the previous one first. The page look filter, when it lands, belongs on the one drawImage call in SharePage.drawPages as a colorFilter. The file is named from reader_share_title (“ · page N", sanitised), which is also `EXTRA_TITLE` so the sheet previews something meaningful; there is no `EXTRA_TEXT`, which makes several targets drop the image, and no watermark. The `Intent` is shared with `FileProvider` (`${applicationId}.fileprovider`, `res/xml/file_paths.xml`), `ACTION_SEND`, `image/jpeg`, `FLAG_GRANT_READ_URI_PERMISSION` and the URI as `ClipData` so the sheet renders a preview, and it is emitted on the same `ReaderViewModel.shareRequests` flow the glitch report already uses — `ReaderScreen` starts whatever arrives there.

Per-comic settings

ReaderViewModel reads the comic’s comic_settings row (ComicSettingsDao) on open: bubblesEnlarged and guided, when not null, replace the reader’s initial off state; readingType, when not null, overrides the global reading type, and the reader’s type row then writes the override instead of the global preference; bubbleScale works the same way for the HUD stepper (override wins, and stepping updates the override when one exists); coverAlone feeds the spread pairing above, splitWidePages the split described above, verticalScroll the continuous strip, pageLook the image adjustment and fitWidth the page fit, both below. Everything else stays global in ReaderPreferencesRepository.

Reading type and direction

What the user picks is a ReadingType (feature/reader/domain/ReadingType.kt) — Comic (default), Manga or Webcomic — persisted with DataStore (ReaderPreferences, key reading_type; a pre-1.2 reading_direction_rtl still reads as Manga / Comic) and exposed as ReaderUiState.readingType, cycled from the ViewModel (ReaderViewModel.cycleReadingType) and from the reader settings menu.

ReadingDirection stays the low-level concept every pager, tap zone and split is written against; the type maps onto it with ReadingType.direction:

type direction mode on open
Comic LeftToRight —
Manga RightToLeft —
Webcomic LeftToRight vertical scroll

The effective type resolves as per-comic setting → the comic’s ComicInfo default → the global type, and its direction lands on ReaderUiState.direction.

The implied mode is an on-open default only, one ladder in ComicSettings.openModeOnOpen (feature/library/domain/ComicOpenMode.kt): the comic’s explicit “Mode on open”, else Strip for a Webcomic, else Guided when “Guided view on open” is on, else Pages. Picking Webcomic inside the reader therefore never throws the current page into a scrolling strip — it changes the direction and persists the type, and a snackbar (“Webcomics read in vertical scroll” · Switch) offers the mode change, exactly as the split suggestion does.

When RightToLeft is active:

All of this is driven by a single pure helper, PageOrder (feature/reader/domain/PageOrder.kt), which maps a logical (reading-order) page or spread index to its physical pager index and back, decides which logical page renders on the left/right of a spread, and mirrors a tap’s zone (previous/center/next). It has no Compose or Android dependency, so it is unit-tested directly (PageOrderTest).

Shared reader chrome

Thumbnail scrubber

Preloading

Regardless of surface, the reader preloads the next N page bitmaps (and, in spread mode, the next pair) through Coil so page turns have no decode latency. N scales down under memory pressure.

Page fit

A dense page on the folded screen is unreadable whole, and pinching it open on every turn is not reading. The fit is therefore the base scale of every page, kept per comic (ComicSettings.fitWidth, a row in the eye panel that flips between Screen and Width and leaves the panel open, because it is a state you keep rather than a destination).

All of the geometry is one pure object, PageFit (reader/domain, unit-tested): the content size for a container and a page aspect, the pan bounds for a scale, the clamp, and the landing offset. Fit screen falls out of the same formulas with content size == container size, so there is one code path, not two.

The per-comic settings screen carries the same value as a Screen · Width choice row (no “Default” chip, since there is no global default), so a whole series can be set to fit width at once.

Reset is one tap of the row back to Screen. There is deliberately no fit height (on a portrait screen showing a portrait page it is fit screen), no remembered arbitrary pinch (a 4× state you cannot see, name or undo), and no global or per-posture default — the setting follows the comic’s page density, not the device.

Page look

Scans differ far more than screens do: a dark 90s scan, a washed-out one and a yellowed newsprint one all want a different curve. The reader offers four named looks per comic (PageLook in reader/domain, persisted as ComicSettings.pageLook by enum name), cycled from one gear-panel row: Original → Brighter → More contrast → Paper → Original, so Original — the reset — is never more than three taps away.

Each look is a ColorMatrix built by a pure function from a contrast about the mid level (128), an optional lift and optional per-channel gain:

They are unit-tested by applying the matrix to a colour rather than by comparing coefficients (PageLookTest): mid grey survives More contrast unchanged, Paper cools it, Brighter lifts black to 14.6.

The filter is applied by wrapping the page content in one saveLayer (Modifier.pageLook: drawWithContent → drawIntoCanvas → saveLayer(paint) → drawContent() → restore()), so the page bitmap, the enlarged-bubble copies and the crescent fill get the same treatment and can never diverge. It applies on every surface — single page, spread (both halves), tabletop, Guided View, the vertical strip, PDF and split halves — with no posture special cases.

Two rules keep it cheap:

It is a draw-time filter only: no decode is invalidated and no detection cache is keyed on it, so cycling is instant and costs no re-decode. It deliberately does not reach the thumbnail scrubber (navigation) or library covers (identity), and the glitch report’s page.jpg stays unfiltered so it still shows what the detector saw — the look is recorded as a pageLook field in report.json instead.

Night tint is orthogonal: it stays global and is drawn above the page and below the chrome, so it composes over an adjusted page. The two rows sit next to each other in the gear panel so the relationship is legible.

The per-comic settings screen spells the four looks out as a choice row (again with no “Default” chip), so a yellowed run can be set to Paper in one go.

Night tint

An optional warm amber scrim for comfortable low-light reading, toggled from the HUD. It is drawn as a translucent layer above the page content and ambient backdrop but below the top/bottom chrome, so it never dims the controls themselves. The preference is persisted with DataStore (ReaderPreferencesRepository) and restored on the next session; it only affects the reader, never the library.