Adds eviction + reload logic to the continuous/webtoon scroll handler.
Previously, `_readProgressListener()` triggered next‑chapter preloading
but never called `_checkAndReloadEvictedPages()` or `preloadManager.evictOldChapters()`,
so old chapters accumulated in memory.
This patch mirrors the paged‑mode behavior:
- Reload evicted pages for the current chapter
- Evict pages from older chapters
- Remove corresponding crop‑border entries
- Prefetch pages in order
All actions are gated behind `pageChanged` to avoid repeated linear scans during flings.
Introduce `_evictedChapterIds` to track chapters whose page data has been evicted, enabling fast O(1) checks instead of scanning all pages.
Integrate eviction tracking into preload/eviction flows, clear the set on resets, and expose `isChapterEvicted` through both the manager and `ReaderMemoryManagement`.
Update `ReaderView` to skip reload work when a chapter is known not to be evicted.
The memo in didUpdateWidget compared elements by identity. Every caller
collects into a freshly built list inside its own build, so identity
never matched, the group keys were recomputed and the whole list sorted
again on each rebuild.
Comparing contents instead. listEquals walks the list once, which is
nothing beside sorting it, and it matches what callers actually do.
The size of it: five rebuilds of a 50,000 entry list went from roughly
290ms to 70ms here, and only the first of those five now does any work.
That list is not hypothetical. A repo with broken source JSON generated
an entry per language and produced tens of thousands of them, at which
point the list stopped painting and would not scroll.
Tests count groupBy calls rather than timing anything, since the cost is
dominated by the comparator and a clock makes for a flaky test. One
covers a rebuild with equal contents, one covers changed contents still
being sorted, and one pins the order so the memo cannot quietly change
what is shown.
Grouping the sources with nothing to show left them rendering zero height
rows inside a lazy list that estimates extents. Scrolling up built more
rows, they resolved, they collapsed to nothing, and the content shrank
under the scroll position, so the view fought the finger and the top was
only reachable by flinging hard on the first try.
Every row is built now instead of lazily, which keeps the extent stable
once each source resolves. There is one row per installed source, each is
light, and a global search queries all of them anyway.
The tradeoff is that every source now starts its search when the screen
opens, rather than as it scrolls into view.
Each cover sat directly inside SizedBox(width: 132), which hands its
child a tight width. That silently overrode the cover's own width, so
every cover rendered 132 wide and only its height ever changed. The
covers looked near square, the current entry and the rest differed in
height alone, and editing the widths did nothing at all.
Centring the cover inside the rail restores its own width. The connector
stub below it was already centred; the cover was not.
The aspect now comes from the artwork rather than being chosen: AniList
serves these covers at 230x320, so the box uses 320/230 and BoxFit.cover
has nothing left to crop. The previous 1.46 was taller than the art and
trimmed the sides.
Covered by a test, since the failure mode is invisible in the source:
the widget asks for one width and silently gets another.
A source that failed held a full 260px slot showing an error panel and a
Retry, so a few broken extensions pushed the sources that did return
results off the screen. A source that found nothing held the same slot to
say so.
Both are the same non-event to someone scanning results. They are now
kept out of the list entirely and collected into one collapsed group at
the bottom, so the results are never interleaved with dead extensions.
The rows stay mounted and simply render nothing, so nothing refetches
and the group fills in as each source resolves.
The retry is gone. It made sense on a single screen, which is where the
shared ErrorState came from, but not for one source among many: these
failures are almost always a broken extension returning the same error
every time, not a transient blip. Each row in the group keeps its reason,
since that is the only clue about which extension is broken, and still
opens the source.
Also fixes every row sharing key: ValueKey(query), which is not a valid
sibling key, and drops the per row Scaffold that forced the row to expand.
Adds one plural string for the group header.
The two cover sizes were not the same shape: 100x146 is 0.685 and
120x174 is 0.690, so the current entry cropped its artwork slightly
differently from every other row in the rail.
Both are now derived from a single aspect constant, so they can only
differ in size. The current entry is 10% wider rather than 20%, which is
enough to feel given the accent ring already marks it, and no longer
reads as a jump in the rail.
This replaces an earlier pass that rounded everything to a clean 2:3.
That changed the shape rather than just unifying it, and read worse on
the device.
The scroll onto the current entry worked on macOS but not on iOS. It was
a race, not a platform difference: _didAnchorOnCurrent was set before the
attempt, and the post frame callback then returned silently when the row
was not laid out yet, so a single early miss disabled the anchor for good.
It now retries across frames until the row exists and the scroll view
knows its extent, giving up after ten. The route can still be animating
in when the data arrives, which is when the first attempt misses.
The ink surface wrapped the whole row including the connector stub, so
focusing an entry on TV or desktop painted a square tinted block that
ran down over the rail and closed up the visual gap between rows.
The connector is now drawn after the ink surface rather than inside it,
aligned under the cover by reusing the same padding and rail width. The
tint is also rounded and clipped to match, instead of being a hard
edged rectangle.
The timeline always started at the top. Mid way through a long franchise
that looks like the beginning of the list, hiding the fact that there
are earlier entries above and later ones below.
The current entry now carries a key, and a post frame
Scrollable.ensureVisible(alignment: 0.3) brings it into view with the
previous entry still partly visible above it. On TV the remote lands on
that row too, instead of row 0.
No scroll offsets are computed. The framework positions it, and both
edge cases fall out for free: nothing happens when the current entry is
already first, or when the franchise is short enough that the list
cannot scroll at all.
The rows are built eagerly rather than lazily, because a lazily built
row that is still off screen has no context for ensureVisible to reach.
A franchise timeline is short, so that is a fair trade.
The app was still on the pre-scene UIApplicationDelegate lifecycle. That
is what disabled Picture in Picture: PiP opens a second UIScene, which
re-entered didFinishLaunchingWithOptions and re-ran plugin registration,
and connectivity_plus segfaulted. AppDelegate also reached the binary
messenger through window?.rootViewController as! FlutterViewController,
and under UIScene the app delegate owns no window, so that force-cast
would crash outright.
Follows the migration flutter_tools performs for unmodified templates,
which skips this project because AppDelegate is customised:
- Info.plist declares UIApplicationSceneManifest with Flutter's own
FlutterSceneDelegate and the existing Main storyboard.
- Plugin registration and the libmtorrentserver channel move into
didInitializeImplicitFlutterEngine, which fires once per engine rather
than once per scene. The channel takes its messenger from the engine
bridge's applicationRegistrar, so the force-cast is gone.
- The manual AppLinks.shared.getLink(launchOptions:) block is removed.
app_links already conforms to FlutterSceneLifeCycleDelegate and
FlutterSceneDelegate forwards scene:willConnectToSession:options: to
it, so it picks up cold-start and warm links itself. Those launch
options are never populated under UIScene anyway.
- UIBackgroundModes gains audio, needed for PiP and background playback.
PiP itself is NOT re-enabled here. Its original blocker is fixed, but
the media_kit fork has since moved the API: enableAutoPictureInPicture
no longer exists and PiP now lives on VideoController.pictureInPicture
as a controller taking a native libmpv handle. That port needs device
testing, so it gets its own change. The stale comments saying to
re-enable PiP "after the UIScene migration" are corrected to say this.
Needs verification in Xcode on a device: launch, deep links, the torrent
server channel, and backgrounding.
Four silent catches on network paths now log instead of discarding the
error. Each had a user visible consequence and no way to diagnose it:
a failed source filter drops sources from the list, a failed epub parse
shows the not-found placeholder, a failed index_v2 redirect makes a repo
look empty, and a failed repo fetch shows no sources at all. They go to
AppLogger, which is off unless the user enables logs, so nothing changes
by default and the failure is recoverable from a bug report.
The other empty catches are left alone deliberately: teardown paths like
stopServer and disposing a headless webview, and formatting fallbacks
that already have a sane default.
Eleven hardcoded neutrals become theme colours. The palette is chosen at
runtime, so a fixed grey is wrong on some schemes: the read chapter dim
in updates, the tab bar dividers, the empty category state, the disabled
webview arrows, and the about divider.
Greys that are meant to be literal are untouched: the reader background
named "grey", the novel colour picker swatches, player chrome sitting on
video, and the icon on the sync tile's fixed dark box.
Raising the interface scale on desktop left the right and bottom edges
of the window blank. AppUiScale measured MediaQuery.size, which can
differ from the constraints it is actually given there, and its FittedBox
sat under loose constraints, so it shrank to the smaller design child and
kept it in the top left instead of covering the window.
Measure the real box with a LayoutBuilder and pin the FittedBox to it
with an explicit SizedBox, so the scaled subtree always fills the window.
Safe area insets are still converted into design space, so padding stays
correct.
Validated on desktop.
Both screens were unusable with a remote. Neither had a single autofocus
or any isTv branch, so opening either left nothing focused: the d-pad did
nothing until the user guessed a direction, with no ring to show where
they were. Their rows also stretched the full width of a TV or desktop
panel, and the watch order role badges were hardcoded Colors.grey and
Colors.blueGrey, which washed out on dark themes with a light accent.
Recommendations becomes a poster list: cover, an accent score pill, the
title, a two line capped synopsis and genre chips. It stays one column on
a phone and reflows to two above 700px, capped at two, so rows never run
into empty space.
Watch order becomes a vertical timeline rail on every form factor,
centred at 820px on wide windows. The current entry is enlarged with an
accent ring. Role badges are now theme safe: "currently watching" fills
with the accent and computes its label colour from that fill's luminance
rather than the app brightness, "up next" is accent tinted, "previous" is
a neutral tint of the foreground.
The first card or row claims focus on TV, so a remote lands somewhere.
Both files now share one set of alpha constants. They previously
disagreed six ways about the same concepts: focus wash 0.16 against 0.12,
neutral tint 0.08 against 0.09, secondary text 0.7 against 0.6, badge
radius 5 against 6, cover aspect 0.681 against 0.685 and 0.690, and three
sub pixel type steps. Covers are 2:3 at both sizes and the type scale is
whole pixels.
Fetching a source page is the app's longest routine wait and it showed a
centred spinner, which says nothing about what is coming and reads as a
stall.
CoverGridSkeleton lays out the shape the covers will land in. It renders
through the real GridViewWidget with the same gridSize and aspect ratio,
so the delegate, spacing, padding and TV insets cannot drift from the
loaded grid and nothing shifts when the data arrives.
The tint is the theme foreground at low alpha rather than a fixed grey,
since the palette is chosen at runtime. The pulse rebuilds a dozen plain
boxes instead of wrapping the grid in an Opacity, which would force a
save layer every frame, and it holds a flat tint when the platform asks
for reduced motion.
List display keeps the spinner: it has no fixed cell shape to stand in
for.
Nine screens rendered a bare Center(child: Text(error.toString())), so a
failure showed the user a stack trace with nothing to do about it, and
looked different in every place it appeared. They now use ErrorState,
each wired to a retry that re-runs the work that failed: invalidate the
provider for the AsyncValue screens, reset the flags and call the init
again for the ones holding _errorMessage in state.
retry was already translated into fifteen languages but only reachable
from reader image retries and the download queue. It is now offered
wherever a screen fails.
manga_home_screen keeps its own layout: it already has source specific
recovery (refresh through the right provider, and a webview for
Cloudflare), so only the raw cause is demoted to match.
ErrorState gains autofocusRetry because the anime player deliberately
autofocuses its back button for TV, and two widgets asking for autofocus
in one scope resolve arbitrarily.
The root shell rendered LoadingIcon for both the loading and the error
branch of migrationProvider, so a failed migration left the app on a
blank splash forever with no message, no cause and nothing to act on.
Add a shared ErrorState widget (readable line, the technical cause kept
but demoted, optional retry) and use it for the error branch, wired to
re-run the migration. Screens currently hand-roll
Center(child: Text(error.toString())) in ten places; those move over
separately.
LoadingIcon also hardcoded a white background with a black icon, which
meant a full screen white flash on every cold start under a dark theme.
Both colours now come from the active scheme.
Before, every keystroke was saving to the DB.
Now there is a debounce timer, so it only saves, when the user is pausing for 1.5 seconds.
Until then, the keystrokes are saved to memory.
Also saves on exit, in dispose.