The home of the collection: import comics, browse covers, resume reading.
ACTION_OPEN_DOCUMENT_TREE). We takePersistableUriPermission and store the
tree Uri in DataStore (LibraryPreferences), so the folder survives restarts.ComicScanner walks the tree recursively with DocumentsContract (no extra
dependency), collecting .cbz/.cbr/.cb7/.cbt/.7z/.tar/.pdf by extension, or by comic MIME type
when a provider hides the extension. Extension detection is tolerant of trailing
suffixes providers append to duplicate downloads (e.g. Comic.cbr (1)).
Subfolders name the series.LibraryScanError in the UI (AccessLost for SecurityException, i.e. the
tree grant vanished after a reinstall; ReadFailure for anything else), and
always clears the scanning state instead of leaving an empty shelf and a
stuck spinner.OPEN_DOCUMENT)
without adding it to the library.pageCount/coverPath left null, and covers fill in afterwards (below).KapowRoot
hooks Lifecycle.Event.ON_START (LifecycleEventEffect) and calls
LibraryViewModel.onForeground(), so a file dropped into the folder while the
app sat in the background is on the shelf by the time the user looks at it,
without touching refresh. The trigger sits in KapowRoot, not in
LibraryScreen: the root stays composed across internal navigation, so
closing the reader or leaving the settings screen does not rescan and
going back from a page turn stays instant. Because the activity declares
configChanges for orientation/size, folding and unfolding does not restart
it either.onRefresh), and
implicitly when a folder is picked (setFolder scans the new tree).assets/sample.cbz) gives a freshly installed app
something to open before the user picks a folder. On first launch
seedSampleIfNeeded copies it into internal storage
(filesDir/sample/sample.cbz) and inserts a library row whose documentUri
is a file:// Uri, titled from sample_comic_title. A sampleSeeded flag in
LibraryPreferences guards it: the seed runs exactly once, so deleting the
sample keeps it gone rather than resurrecting it on the next launch.file:// (not a SAF content://), two
paths special-case it: a folder re-scan’s prune step skips locally-managed
comics (a SAF scan never lists them, so they must not be treated as missing),
and Delete comic removes the backing file directly instead of through
DocumentsContract. It reads and deletes like any other comic otherwise.KapowDatabase (core/storage) holds the library tables, exposed as Flows
through LibraryRepository and combined into LibraryComic domain models for
the UI. Per-comic reading settings live in their own table keyed by document
Uri (below); page detections are documented in docs/ml-runtime.md.
Comic(
id, documentUri, displayName,
series, issueNumber, year,
pageCount?, coverPath?, coverPage, coverAmbient?, addedAt, favorite,
storyTitle?, publisher?, writer?, penciller?, inker?, colorist?,
summary?, readingType?, metadataVersion,
rating, metadataEdited,
)
ReadingState(
comicId, pageIndex, completed, updatedAt, shelved,
)
ComicSettings(
documentUri, readingType?, coverAlone, bubblesEnlarged?, guided?,
bubbleScale?, splitWidePages, splitSuggested, verticalScroll,
)
ReadingSession(
id, comicId, startedAt, endedAt, pages, mode, finished,
)
Bookmark(
comicId, pageIndex, createdAt,
)
ReadingList(
id, name, createdAt,
)
ReadingListEntry(
listId, comicId, ordering,
)
documentUri is unique; re-scans ignore comics already registered.contentHash is the comic’s real identity (below). It was added in schema
v13 (migration 12→13), nullable and indexed, so an upgraded library
keeps every row and fills the hashes in lazily.readingType on both comics and comic_settings replaced the older
readsRightToLeft / rightToLeft booleans in schema v19 (migration
18→19, which rebuilds both tables). The migration preserves the setting —
1 becomes 'Manga', 0 becomes 'Comic', NULL stays NULL — so an
upgraded library keeps reading manga right-to-left.ReadingList and its join table ReadingListEntry hold the reading lists
(below). They arrived in schema v17 (migration 16→17). The entry is keyed
by the comic row id, not by documentUri, so a relinked file (renamed or
moved) keeps its place in every list for free. Both foreign keys cascade:
deleting a list drops its entries, and pruning a comic drops it from every
list without any code in the prune path.ReadingSession records one row per reading session and feeds the stats
screen and the hero’s estimate. It cascades on the comic and is pruned after
400 days; see docs/stats.md.Bookmark holds the pages the reader marked, one row per page, primary key
(comicId, pageIndex), added in schema v15. It is keyed by the comic row
id, not the document Uri, so bookmarks survive a rename or move, and it
cascades on the comic so deleting a comic takes its bookmarks with it. There is
no cap and nothing global: bookmarks belong to one comic. Toggling split wide
pages rewrites them through the same logical page → source page → first half
conversion the reader uses to keep the reading position, so two halves of a
split page merge back into one bookmark. The reader shows them on the counter
row and in the thumbnail scrubber (docs/reading-modes.md); the per-comic
screen shows the count as a detail row.ReadingState powers “continue reading” and cross-session position restore
(see foldable.md for the in-memory ReadingPosition this maps to). The
reader seeds its initial page from it and, on each page turn, saves the index
and marks completed at the last page. completed is sticky: once set it stays
true on later saves even if the reader pages back into the comic, until the
comic is explicitly marked unread.completed is also settable by hand from the library: marking a comic read
writes a ReadingState at the last page; marking it unread deletes the row
(clearing its resume position). favorite lives on the comic itself and is
toggled the same way. Both are reached by long-pressing a cover. The favorite
column was added in schema v2 (migration 1→2).DocumentsContract.deleteDocument and removes its library row, cover, and
reading state. If the delete fails (e.g. the folder was granted read-only by
an older pick), the file and its entry are left intact rather than lying about
a deletion that did not happen; re-picking the folder grants write access.ComicInfo.xml columns (storyTitle … readingType) and
metadataVersion were added in schema v11 (migration 10→11). They are
all nullable except metadataVersion, which defaults to 0 so every existing
row is re-read once by the cover pass (below). Nothing else is touched, so a
library upgraded in place keeps its covers and reading positions.rating (0 = unrated, 1..5) and metadataEdited were added in schema v14
(migration 13→14), both NOT NULL DEFAULT 0, so an upgraded library keeps
every row unrated and unedited. rating is set from the Details section’s
star row — tapping the star that is already the rating clears it back to 0 —
and is per comic: a series settings screen hides the Details section, so it
never rates a whole stack. “Highly rated” means rating >= 4
(LibraryCatalog.highlyRated).PageLoader;
a persistent panel cache is deferred to a later phase.Every table keys a comic by its documentUri (comics, comic_settings,
page_detections; reading_states, reading_session and reading_list_entry
hang off the comic id). A document Uri encodes the file’s path, so renaming or moving a file —
or re-picking the folder after a lost grant — hands the scan a Uri it has never
seen and would strand progress, favourites, per-comic settings, cached
detections and the cover on a row whose file is gone.
The content hash is what actually identifies a comic, the same rule
YACReader uses: SHA-1(first 512 KB of the file + the file size)
(ComicIdentity.hash, pure; ComicHasher does the reading). The prefix keeps
it cheap on a 70 MB archive, and the size makes two files that share an opening
block distinct.
documentUri stays the row key; the scan reconciles by hash instead
(LibraryReconciler.reconcile, pure domain, given the arrivals and the rows
whose file is gone):
Dispatchers.IO. A
rescan of a library with no new files reads no bytes at all, so the automatic
foreground rescan stays as cheap as it was.LibraryRepositoryImpl.relinkComic rewrites the Uri on the comic, its
settings and its detections in one Room transaction, and re-derives the
name-based fields (displayName, series, issueNumber, year) from the
new file name, resetting metadataVersion so the next pass re-reads
ComicInfo.xml over them. The row id never changes, so progress, favourite,
sessions and the cover file follow for free.AccessLost, i.e. the grant vanished) still prunes
nothing, so rows survive until a folder is picked again and the hashes can
match the files back to them. Only a successful scan that lists the folder
and finds no file with a row’s hash removes it.Hashes are backfilled lazily by fillMissingDetails (the cover pass), which
already walks every comic after a scan: a row with no hash gets one there, while
its file is still where the library expects it. Nothing hashes a comic twice.
LibraryComic list. LibraryCatalog.grouped buckets comics by a
normalized series key (trimmed, case-insensitive) preserving the sorted
order. A bucket with two or more volumes becomes a LibraryEntry.Group
(a folder card); a lone comic stays a LibraryEntry.Single and opens directly.
Since series is the file name with the volume/issue number, year, and volume
marker stripped (ComicNameParser), only names that match apart from the
number collapse together.grouped in LibraryPreferences,
default off). Filters apply before grouping, so a filtered group that drops to
one volume renders as a single card.An ordered, cross-series list — a crossover, a re-read, anything the file names cannot express. Lists are hand-ordered, never re-sorted.
LIST · 3/12 read (the same
library_group_read the series header uses, over the list’s comics) and the
trailing slot swaps the Recent sort toggle for the list “⋮” (rename, delete):
a hand-ordered list has no sort. The grid shows the list in ordering, filter
chips and search still narrow it, “Continue reading” collapses
(continueReadingVisible also requires openedList == null) and grouping
applies — the Layers toggle is a view over the list, never a change to it.
A stack inside a list means “the issues of this series that are in this
list”, and opening it shows them in list order, not series order, because
LibraryCatalog.grouped keys a LinkedHashMap by series: a group lands at the
list position of its first member and keeps its members’ order, so a
crossover’s shape survives grouping for free. Grouping never writes
ordering; Move up / Move down are hidden while grouped (a position in a
grouped view is not a position in the list), and “Remove from list” on a stack
takes every member out.Add to list…, and it always works on a set of comics: the
selection row’s first square (one comic or twenty — see “Selection mode”), or
the series menu, which passes the whole group in its shelf order. The dialog
is headed by the comic’s title (one comic), by the series name with the
selected count under it (from a series), or by the count itself (from the
selection row), and holds a TriStateCheckbox per list plus a “New list…”
row that swaps the body for one text field — and while that field is up the
dialog retitles itself New list, with the count moving to the subtitle, so
it never reads “2 selected” over a name box. Creating a list from that dialog creates it and
adds every comic in order. The box is On when all of the payload is already
in that list, Indeterminate when only some of it is and Off when none is
(ReadingListMembership.of); tapping Off or Indeterminate adds the ones
that are missing (missing, in the given order), tapping On removes them
all — a plain checkbox would have silently misreported a mixed selection.
There is no snackbar: the box is the confirmation and unticking is the undo.
Names are free text, trimmed, duplicates allowed; an empty name disables
Create. New members append at max(ordering) + 1.Move up / Move down in the selection “⋮” with exactly
one comic ticked (a set has no place to move to), hidden at the ends
— no drag handles inside a LazyVerticalGrid. ReadingListOrder is pure and
works on the ordered comic ids; the repository writes the resulting order back
as ordering 0…n-1.ordering
(removal leaves the gap, so the comics land back where they were); undoing a
delete re-creates the list with its id, name, date and members. Deleting needs
no confirmation dialog — no comic is lost — and the shelf falls back to All
comics immediately.KapowRoot passes LibraryCatalog.nextInSeries(…) whatever is on the shelf,
so the vertical strip and the “next issue” action both follow the series and
stop when it ends. Chaining across a list once existed and was wrong — a comic
opened from a folder-like list ran on into an unrelated series.openedList), like openedSeries, so
returning from settings or the reader lands back in the list. It is not
persisted: the shelf opens on All comics.library_list_empty in the grid area.CoverGenerator decodes the comic’s coverPage through ComicSourceFactory at
grid resolution and writes a JPEG to internal storage
(filesDir/covers/{id}_{coverPage}.jpg), recording coverPath, coverPage,
pageCount and coverAmbient: the cover’s ambient
colour (same Palette rule as the reader’s ambientColorInt), stored as an
ARGB int so the library can tint the hero without decoding the cover again.ComicInfo.xml (below): the archive is already open, so
metadata costs one extra entry read rather than a second pass over the
library. It runs for every comic whose cover is missing or whose
metadataVersion is behind METADATA_VERSION.CoverPickerScreen, a grid of every page (thumbnails decoded lazily at half the
cover width through PageThumbnails, which keeps one ComicSource open for the
screen’s lifetime). Tapping a page writes coverPage, regenerates the cover
through the same CoverGenerator and returns to the library; “Use first page”
resets to page 0 and stays. coverPage is a pre-split page index, so
splitting wide pages never shifts it, and it is clamped to the page count on
every generation (ComicCover.page).{id}_{page}.jpg) so a new choice is a new
coverPath: that is what invalidates Coil, which keys its memory cache on the
model. The previous file is deleted after the new one is written, and deleting a
comic removes every covers/{id}_*.jpg (CoverGenerator.deleteCovers).
Regeneration recomputes coverAmbient, so the hero wash, the settings header and
the reader’s initialAmbient follow the new cover for free — and a series stack,
which renders its first issue’s cover, follows that issue’s choice.pageCount is still null (the cover pass has not
run yet); a page that cannot be decoded leaves coverPath/coverPage untouched
and reports it in a snackbar rather than falling back to page 0.The shelf uses a “Comic Red & Ink” palette local to the library UI: pure-black
OLED ground, a comic-red accent (matching the reader theme’s primary) with an
amber highlight and green for completed. Progress shows as a red ring on the cover
corner (percent) and a red→amber bar under in-progress comics; finished comics get
a green “Completed” badge and favorites an amber star. The most recently read
unfinished comic becomes the hero: a wide card washed with the cover’s
ambient colour, showing the cover, progress and an estimated time left
(LibraryCatalog.minutesLeft, fed by the pace measured from the reader’s own
sessions and falling back to 45 s per page — see docs/stats.md); any other
unfinished comics follow in a row of smaller resume cards.
Both carry a “×” that hides the comic from “Continue reading” without touching
its progress (ReadingState.shelved = false); the next saveProgress upsert
re-shelves it, so a comic reappears as soon as it is read again.
GridCells.Adaptive): the minimum
cell is 112 dp on compact widths (folded, 3 columns) and 158 dp otherwise
(unfolded, 5 columns). Only the cover is clipped to the card shape; the title
and count sit outside so rounded corners never cut glyphs.LibrarySort) is not a fifth chip — a clock icon
at the trailing end of the shelf title row, accent-tinted while on. It sat in
the chip row before and clipped off the edge of a folded 360 dp screen, which
also read as if “Recent” were a filter.continueReadingVisible; the list itself is always computed, so it
survives the animation). Instead of vanishing, it collapses — fade plus
shrinkVertically — and it shares a single grid item with the header, so
switching filters slides the shelf header down rather than yanking the chip
row ~185 dp up from under the user’s finger.LibraryCatalog.search → SearchQuery). See “Search syntax” below.LibraryRepository.reshelve); progress is never lost.
The swipe state is keyed by comic so the next hero starts settled, and the
row scrolls back to its start whenever the hero changes (a LazyRow would
otherwise keep the old first item anchored and hide a reinserted one).docs/stats.md) are visible; choose
folder and refresh live behind “⋮”. The empty state keeps the large
“Choose folder” button. The group toggle keeps the same Layers glyph the
series count badge uses in both states and signals grouping only with the
accent colour, like the search button does while open — a folder icon would
collide with “choose folder” in Settings and onboarding.openedSeries), so returning from the
settings screen lands back inside the series.Anything you can do to one comic you can do to a handful, because there is only one code path: the batch one.
stickyHeader that normally holds Todos / Sin leer / Leídos /
Favoritos crossfades (150 ms) to the selection row and back. Nothing else on
the screen moves: the toolbar keeps scrolling, the shelf title stays put, the
grid’s contentPadding is exactly what it is without selection, and no bar is
inserted above or below anything. Losing the filter pills for the duration
costs nothing — changing the filter would only have narrowed the selection
anyway (see below). The series screen has no filter row, so there the same row
is added as its own stickyHeader while a selection is live.GhostAction “✕” identical to the search/layers/stats squares one
line above, then the count as a selected FilterPill — Accent ground,
white Check glyph and a tabular-figures number, ✓ 3 folded and
✓ 3 seleccionados from 600 dp, not tappable — then weight(1f), then the
actions as GhostAction squares: add to list, remove from list (only inside
an open list), read/unread, favourite, delete, “⋮”. Delete is the same square
in the destructive key (Danger ground at 15 %, Danger glyph), the exact
mirror of GhostAction(active = true), so no red word idles in the row.BoxWithConstraints gives the row its
width and inlineActionCount spends it on 48 dp squares in the order above;
whatever does not fit falls into “⋮”, which is always present. On the folded
475 dp screen four actions fit, so inside an open list delete is the one
that overflows — the safe failure. Unfolded, all five fit.CardLine hairline on CardShape — almost
invisible, but the whole grid becomes outlined tiles that say “these are all
pickable” with no dimming, no glyphs and no Material checkbox. A selected
cover keeps 2 dp Accent, springs to 0.92 on a graphicsLayer inside the
sharedCover node (so shared-element bounds never animate and neighbours
never move), and takes an Accent check badge top-start with FavoriteBadge’s
geometry — the one collision, so a selected card’s favourite badge yields. The
progress ring, the completed tick and #01 are untouched. Hairline =
selectable, 2 dp Accent = selected.LibrarySelection.reconcile intersects the selection with the shelf, so
comics the new filter hides simply drop out of it instead of being silently
batched, and a delete, a rescan that drops a file or a relink can never leave
a stale id behind.LibraryCatalog.selectable(entries, comics,
grouped) — every comic the grid is drawing, including the issues inside
stacks, which is the one change behind selectable stacks, drags that cross
them and a select-all that no longer silently omits grouped series. Getting
this list wrong once made the mode enterable but invisible: the shelf renders
state.comics when grouping is off while entries is always the grouped
structure, so long-pressing an issue of a multi-issue series selected a comic
the row could not find. Pinned by LibrarySelectionTest.LibraryContent, gated on state.selection and registered
before the series and open-list handlers (which stay gated on !selecting) —
never inside the row it protects, or a row that fails to render takes the
escape hatch down with it and BACK leaves the app.SnackbarHostState.undoable: batch mark read/unread and batch
favourite/unfavourite restore each comic’s previous flag, and remove-from-list
re-inserts every entry at its own ordering. Delete gets none —
deleteComic removes the file from the device, so an “Undo” would be a lie;
the one confirmation dialog quoting the count
(library_selection_delete_title/_body, plurals; a single comic keeps its old
by-name wording) is the guard.✓ 120 (issues, the same plural as ever), every action runs
over the member comics, and ten Spider-Man stacks in one drag is just a
hundred-odd comics. A stack shows the same states as a cover: 2 dp Accent
and the filled check when every issue is ticked (with the 0.92 scale on the
whole stack, so the offset back-card scales too), a 1 dp Accent border and
a ringed dot when only some are — the tri-state of the add-to-list dialog in
badge form — and the 1 dp CardLine hairline when none are. Its CountBadge
stays at BottomStart and doubles as “this is how many the tick just took”.
Nothing is displaced: a GroupCard’s TopStart and TopEnd were empty already.library_selection_delete_series_note, LibraryCatalog.completeSeries).GroupCard has no menu; it lives on the
series screen’s header “⋮” (tap the stack to get there), still including
Añadir la serie a una lista…, still the cheapest route for one series. When
the selection is exactly one whole stack the selection “⋮” also offers
Series settings, mirroring how the single-comic rows appear there.Modifier.inertWhile, consuming pointer events at PointerEventPass.Initial)
— it stays laid out, so nothing jumps, but a stray tap can no longer open the
reader and throw away a selection being built. Inside a series screen the
header’s “⋮” hides: “Delete series” must not sit one tap from a three-issue
selection.LibraryUiState.selection is a Set<Long> of comic ids owned by
LibraryViewModel; the UI carries it around as one SelectionUi value the
way reading lists travel as ReadingListsUi. LibrarySelection and
ReadingListMembership are pure and unit tested.The long-press that enters selection is the start of the drag; the finger never lifts. A long-press while already selecting re-anchors and starts a new one. A plain drag is never selection — in or out of the mode it is the grid’s scroll, which is the only way to still move around a 211-comic library with a selection live.
SelectionDrag.rangeFromAnchor is pure and
unit tested: base is the selection frozen at drag start, the span is
selectable[min(anchor, current)..max(anchor, current)], and the result is
base + span when the anchor was unselected or base - span when it was.
That one boolean is the undo a hundred-comic drag needs — long-press a
selected card and drag back over your own path to give the run back. Because
the result depends only on the two ends and never on the path, overshooting on
a folded three-column grid and retreating simply shrinks the span, and cards
selected before the drag are never disturbed unless they fall inside it.
Indices are positions in LibraryCatalog.selectable(...) — reading order over
selectable covers only.onSelectRange(ids),
which replaces the whole set, and only when the item under the finger
changes — never per frame, never as N toggles, so a hundred-comic drag costs
tens of ViewModel events, not thousands. Lifting the finger flushes the last
position once more, so the far end of the range can never be lost to a frame
that never came.ComicCard therefore has
no onLongClick any more — two handlers racing for one press is a bug
waiting to happen — and its onClick is untouched. The detector watches the
Initial pass without consuming until the long-press timeout fires, so until
then everything falls through to the grid’s own scroll; once it fires the
detector owns the pointer and consumes, which is also why dragging up into the
pinned selection row cannot press its buttons.layoutInfo.visibleItemsInfo and looks the item key up
in a span map built from exactly what the grid draws — a cover maps to one
index, a stack maps to the whole span of its issues, so a finger crossing
ten Spider-Man stacks takes all of their issues and the haptic ticks once per
stack rather than once per issue. The map and selectable are both a walk
over LibraryEntry.members(), so they cannot drift. Header items and the hero
are in no span at all and are simply skipped.withFrameNanos loop: a linear ramp from
DragSelectMinRate (240 dp/s) at the outer edge of a DragSelectHotZone
(96 dp) to DragSelectMaxRate (900 dp/s) at the very edge. 900 dp/s is a
deliberate ceiling — a folded row is ~174 dp, so ≈ 15 comics/s, a hundred in
about seven seconds and still watchable. The pinned selection row counts as
the top edge: the zone starts at the row’s bottom, found by its sticky-header
key, not at the window top. The loop re-hit-tests every frame, so a
stationary finger in the hot zone keeps extending the range as content slides
under it, and it stops calling scrollBy once canScrollForward /
canScrollBackward go false, holding the range at the last selectable item.
No horizontal auto-scroll.LibrarySelection.reconcile is suppressed while a drag is in progress —
it intersects with the shelf on every emission and would fight the range as
items scroll in and out. onDragSelecting(true/false) gates it, and clearing
the selection resets it.TextHandleMove tick per newly (de)selected card,
coalesced to at most one per 40 ms, plus the count pill climbing live. There
is no anchor indicator: the boundary is already the edge between
accent-bordered and hairline covers.library_selection_drag_hint), through the shelf’s existing host and
gated by a dragHintSeen flag in LibraryPreferences — the same pattern as
the split suggestion. Nothing permanent is added to the row, and Select all
stays in the “⋮”: 100 of 211 is not select-all.SeriesScreen gets the same gesture from the same code — same grid, no
groups, no hero, the simple case.SearchQuery.parse turns the field’s text into a list of terms; every term must
match (they are ANDed). Parsing and matching are pure and unit-tested
(SearchQueryTest) — there is no Room LIKE query, because the library list is
already in memory. Tokens split on whitespace, and double quotes keep a phrase
together (writer:"al ewing").
A bare word matches, case- and accent-insensitively (NFD, combining marks
stripped, so pena finds Peña), a substring of the title, series, story
title, writer, penciller, inker, colorist, publisher or file name. The summary is
deliberately not searched: it is prose, and every common word would match half
the library.
A token with a recognised key: prefix narrows to one field. Keys have Spanish
aliases, because the keys are the one piece of syntax a Spanish user has to
guess; accents are folded, so año and ano are the same key.
| term | alias | matches |
|---|---|---|
series:hulk |
serie: |
the series |
title:descent |
titulo: |
the ComicInfo story title |
writer:ewing |
guionista: |
the writer |
penciller:bennett |
dibujante: |
the penciller |
inker:jose |
entintador: |
the inker |
colorist:mounts |
color: |
the colorist |
publisher:marvel |
editorial: |
the publisher |
year:2018 |
año: |
the year exactly |
year>2015, year>=2015, year<2000, year<=2000 |
año> |
year comparisons |
rating:4+ |
valoración: |
rated at least N stars (+ is sugar for >=) |
rating:3, rating>=4, rating<2 |
valoración> |
rating exactly, or compared; unrated is 0 |
added:30d |
— | added within the last N days (d only) |
is:reading |
— | started and unfinished (pageIndex > 0 && !completed) |
is:unread, is:read |
— | accepted, but the filter chips already own them |
Rules that keep the field forgiving — it never shows a syntax error:
foo:bar) is not an error. The whole token becomes a
bare word, so a colon inside a title still searches. So does a recognised key
with a value that will not parse (year:soon).writer:) is dropped.Under the empty field sit three preset chips — In progress (is:reading),
Highly rated (rating:4+) and Recently added (added:30d) — and one
hint line. A preset types its literal
query into the field, so the syntax is learned by seeing it, and resets the
filter chip to All (LibraryViewModel.onPresetQuery) so a chip and a query
can never contradict each other. Otherwise chip and query AND together. Presets
and the hint render only while the field is blank and collapse with the same
fade + shrinkVertically “Continue reading” uses, so the grid does not jump.
When a query is active and nothing matches, the grid area shows
library_search_empty instead of the plain library_filter_empty.
Tapping a cover opens the reader directly; there is no intermediate screen.
“Reading settings” in a card’s long-press menu opens ComicSettingsScreen for
that comic. “Series settings” (stack long-press or the series header “⋮”) opens
the same screen for the whole series (eyebrow “This series”, “Applies to all N
comics”): the chosen values are written to every issue, and each issue can be
tweaked individually from inside the series screen.
core/ui/SettingsControls.kt (SettingsSection,
SettingsChoiceRow, SettingsSwitchRow, BubbleScaleRow), so this screen and
the app settings read as the same page. See docs/settings.md for the row
anatomy.Mode on open — Default (…) · Pages · Guided view · Vertical scroll. The
reader’s three layouts are mutually exclusive, so they are one choice here
rather than two independent toggles. It maps onto the unchanged
ComicSettings fields through the pure pair in
feature/library/domain/ComicOpenMode.kt:
| Choice | guided |
verticalScroll |
|---|---|---|
| Default (…) | null |
false |
| Pages | false |
false |
| Guided view | true |
false |
| Vertical scroll | untouched | true |
Reading it back, verticalScroll wins over guided — the same precedence
ReaderViewMode.of applies — so a comic left in the strip by the HUD reads as
“Vertical scroll” here. The “Default (…)” label spells out the global mode
(“Default (Pages)” / “Default (Guided view)”) from OpenDefaults.guidedOnOpen
and the effective reading type — a webcomic reads “Default (Vertical scroll)”.
reading-modes.md#split-wide-pages) and enlarged bubbles on
open (Default · On · Off). The “Default” chip spells out the global value it
falls back to (“Default (Comic)”, “Default (Off)”). Turning bubbles on
reveals the scale slider (same BUBBLE_SCALE_RANGE and steps as the reader’s HUD stepper)
stored as the comic’s bubbleScale; a series-wide change writes it to every
issue. Page look (Original · Brighter · More contrast · Paper) and page fit
(Screen · Width) follow, carrying ComicSettings.pageLook and
ComicSettings.fitWidth; neither has a global default, so neither offers a
“Default” chip, and both stay in step with the reader’s gear and eye panels
because they read and write the same comic_settings row.
Saved in comic_settings through LibraryRepository.saveSettings; a
row equal to ComicSettings.Default is deleted rather than stored.
docs/reading-modes.md describes how the reader consumes them.page_detections rows so the next open re-runs the models.ComicSettingsViewModel observes the first target’s settings and fans writes
out to all targets; it also exposes the global reading type for the
“Default (…)” label.
KapowRoot derives a typed Screen (Library / Settings / CoverPicker / AppSettings /
Reader) from its state slots and renders it inside SharedTransitionLayout + AnimatedContent.
The cover picker keeps only the comic id in that slot and resolves the row on every
composition, so the picker sees the coverPage it has just written.
Screens cross-fade; the reader additionally scales in from 94 %. The cover is a
shared element between the grid card (or series stack) and the settings header:
Modifier.sharedCover(comicId) (SharedCover.kt) reads the transition and
visibility scopes from composition locals, so a cover outside the animated root
just renders normally. The reader receives the cover’s ambient colour as
initialAmbient, so its backdrop already glows in the comic’s colour while the
first page decodes instead of starting from the neutral default.
ComicNameParser parses series, issue number, year from the
filename with a tolerant, unit-tested pure function. No network needed. Series
falls back to the containing folder name when the filename yields none.ComicInfo.xml (the de-facto
standard written by ComicRack, Komga, Kavita, Mylar…) is read from the archive
itself. See file-formats.md#comicinfoxml for where the file is looked for
and how each container reaches it.ComicInfoParser (feature/library/domain) is a pure function over the XML
text — DocumentBuilder, no IO, unit-tested — returning a ComicInfo. It reads
Series, Number, Year, Title, Publisher, Writer, Penciller,
Inker, Colorist, Summary and Manga, and ignores everything else
(Volume, Count, Genre, Characters, Web, Notes, PageCount,
Pages). A file that is not well-formed XML, or whose root is not
<ComicInfo>, yields null and the comic simply keeps its filename metadata.
mergeComicMetadata(info, parsed, edited) resolves the sources per field:
| Field | Winner |
|---|---|
series |
the hand edit, else the XML when non-blank, else ComicNameParser |
issueNumber |
the hand edit, else the XML when it parses to an Int, else ComicNameParser |
storyTitle |
the hand edit, else the XML |
year |
the XML when non-blank, else ComicNameParser |
publisher, writer, penciller, inker, colorist, summary |
the XML only |
readingType |
the XML only (Manga = YesAndRightToLeft → Manga, any other Manga value → Comic) |
The Details section’s Edit details row opens a dialog over series,
issueNumber and storyTitle (Series is required; Number is parsed leniently
by parseEditedIssueNumber, so #012 and 12 are the same issue). Saving
writes the three columns and sets metadataEdited, which is what
EditedComicMetadata is built from: while it is set the metadata pass never
overwrites those three, whatever METADATA_VERSION says, and a relink keeps
them through a rename too — but publisher, the credits, summary and
readingType keep improving from a better ComicInfo parse. displayName
is never editable.
Reset to file (shown in the dialog only while the comic is edited) clears
metadataEdited, sets metadataVersion back to 0 and re-reads that one comic,
re-parsing its file name, so the values come back as a fresh scan would produce
them. Downstream nothing knows an edit happened: the title, the hero, the cards
and the series grouping are all recomputed from the edited columns.
The display title stays LibraryCatalog.title — "$series #$issueNumber" —
so it silently becomes the real one. displayName always keeps the file name
and is never overwritten: it is the row shown last in the Details section, so
the user can always see what the metadata replaced. Every column here is
searchable, by bare word or by key:value — see “Search syntax”.
Not at scan time. The scan stays instant; the cover pass
(fillMissingDetails) already opens every archive once, and reads the XML
through that same ComicSource, so metadata lands a moment after the covers.
METADATA_VERSION (next to the parser, same idea as DETECTIONS_VERSION in
ml-runtime.md) is stored per comic: bumping the constant makes the next pass
re-read every comic, which is also how a library that predates this feature
gets enriched without a re-scan. It is at 2 since the reading type replaced
the readsRightToLeft flag (schema v18).
"$series #$issueNumber"; when
a story title exists both headline it instead (the hero keeps the series line
above; the card’s #12 badge still carries the number).#12 label and gains the story
title underneath."Marvel Comics · 1/12 read" (the publisher is
omitted when unknown).ComicSettingsScreen scrolled to a new “Details” section: a star rating row,
the summary, then Writer / Pencils / Inks / Colors / Publisher for the fields
that have a value, then the file name, and last an Edit details row. A
comic with no ComicInfo.xml shows one line saying so, plus its file name.
The section is per comic, so it is hidden when the screen was opened for a
whole series.Manga seeds the comic’s default reading type: YesAndRightToLeft →
Manga, any other value → Comic, missing or Unknown → nothing. The reader
resolves the type as per-comic user setting → the comic’s own default → the
global default. There is no UI for it; a user who picks a type for that comic
still wins. ComicInfo.xml has no webcomic flag, so Webcomic is only ever
chosen by hand.Number is often not an integer (0, 1.MU, Annual 1). v1 keeps Int?
and falls back to the filename parse for those; the XML’s other fields are
still used.<Pages> entries carrying DoublePage / FrontCover could later drive
splitWidePages and coverAlone automatically. Out of scope for now.All library strings come from resources (docs/i18n.md). Dates and numbers are
formatted with locale-aware formatters, never hand-built strings.
The gear in the library header opens the app-wide settings screen (global
reading defaults, screen, comics folder + rescan, theme, about). See
docs/settings.md. “Choose folder” is also the primary action of the empty
library.