Skip to content

Work/2026 09 14 - #40

Open
professorDeveloper wants to merge 51 commits into
masterfrom
work/2026-09-14
Open

professorDeveloper wants to merge 51 commits into
masterfrom
work/2026-09-14

Conversation

@professorDeveloper

Copy link
Copy Markdown
Owner

All bugs fixed Ready to new version

## The switch

It already had an animation and it read as a flash: the cover filled the
screen from nowhere, in the app's own background colour, with nothing
tying what appeared to the thing that had just been pressed.

It opens from the chip now — a circle growing from where the finger was —
and each mode has a colour of its own inside it: Watch keeps the brand
teal, Manga is ink violet, Novels amber. Those three appear here and on
the chip that starts the switch, and nowhere else; the app is still teal
on black everywhere it was.

The mark hands off to the mode's glyph rather than sitting there: a play
triangle, four comic panels, four lines of text, each drawn by a single
pen travelling every stroke in turn. A fade says an image appeared; a
stroke says something made it. A light haptic lands as the cover takes
the screen, and the whole thing is skipped for anybody who has asked for
less motion.

## The hub

It opened at the alphabetical top, so with sixty-seven sources installed
there was no sign of which one you were on.

It opens on it now, two rows down from the top edge so it reads as one
entry in a list rather than the first thing in it, and that row is
outlined so it can be picked out once you get there. Done in two passes
because the rows are cards that grow with the text scale and the list is
lazy: an estimate gets close enough to build the row, and
`ensureVisible` corrects whatever the estimate got wrong.

Verified on the emulator, by recording the transition and stepping
through it frame by frame.
There was no TabBarView on this page. The body was a single scroll view
rebuilt from `_tabs.index`, with the app bar, the search field and the
category chips all inside it. Three things followed from that, and all
three were reported: swiping between tabs did nothing, changing tab threw
the whole list away and built another, and the chips slid sideways out of
line with the tabs they describe.

Title, tabs, search, language and chips are a fixed header now, over a
real TabBarView. Each tab keeps its own scroll controller and its own
position, so coming back to one returns to where you were rather than to
the top.

## Language

Picking a language barely shortened the list, because half the sources
declare none — CloudStream carries no language metadata and Sozo's own
providers never needed one — and the old filter let every one of them
through.

It splits the list instead of filtering it. A source is matched on what
it declares or on what its name, id and host give away: HindiSubAnime is
Hindi, a `.com.tr` host is Turkish. What is left says nothing either way
and goes to the end under a heading that admits as much. Hiding those
would have taken away the two dozen providers most people came for.

The test that asserted the old mechanic — search scrolls away, chips rise
to meet the tabs — now asserts the new one: the header does not move.
Four screens were doing one job. "Sources" listed what was installed and
carried an "Add source" button; "Add source" was a second screen listing
what could be installed, with a ⋯ in its corner; behind that, a third
screen listed the four extension ecosystems; behind each of those, a
fourth listed its repositories. Two of them were called Sources and two
of them added sources.

It is two levels of one screen now — Installed and Add source — with the
repositories a row at the bottom of Add rather than a page behind a
corner button. The catalogue itself is the same screen it always was,
minus its app bar: this one already has a title, and two app bars stacked
inside one screen is not a screen.

The modes moved down with the list they belong to. Pills rather than a
second row of tabs, because tabs below tabs read as one navigation four
levels deep, and this is a switch between what you have and what you
could have.
The overflow menu had eleven rows. The first four were about where the
title goes — Watch Later, Watched, Follow series, Move to private — and
the rest were about sending it somewhere: find other sources, play on TV,
share, copy link, report. Two different questions, one list.

The four moved to the button that already means "keep this": the tick in
the app bar. It used to toggle My List on its own, which was one tap for
that one list and no way at all to reach the other three except through
the menu. It opens a sheet now where every list the title could be in is
visible at once, with which ones it is already in — something nothing
showed before.

Move to private stays a row with a chevron rather than a checkbox: it is
a move that opens its own flow, and a box that ticks and then asks
something else would be lying about what the tap does.

The tick's accessibility label is one word now too. It used to announce
"Remove from My List" on a button that, after the sheet, might do nothing
of the sort.

AniList and MyAnimeList stay in the menu for now. They are meant to
become one live row in the page body — "AniList · Watching 5/12" — and
that is worth doing once, with the rest of the tracking work, rather than
twice.
Home has always been built from whichever source is current, so what you
see is limited to what one site carries and disappears the day that site
does. A catalogue is the other way round: AniList's or TMDB's view of
what exists, with the app finding a source for any title you open.

## Choosing one

The switcher sheet gets a Catalogue row above the sources — AniList and
TMDB, in their own colours so they do not read as a fourth mode. Picking
one stores `cat:anilist` or `cat:tmdb` where the current provider id
lives, and the home repository branches on the prefix the way it already
does for cs:, an:, mn: and my:. Nothing that reads "the current source"
had to learn a second concept; the provider bloc only had to stop
treating an id it cannot find in the list as a reason to fall back.

## Opening a title

A catalogue card has nothing behind it to play. The detail bloc, seeing a
catalogue id, asks CatalogueResolver first: every installed source of the
right kind is searched for the title at once — the fan-out and ranking
that already existed for "this source died, find me another" — and the
closest answer is loaded as if the card had pointed at it all along. The
year breaks ties between a title and its remake. A confident match ends
the search early; a weak one opens once but is not remembered; anything
above the floor is kept locally so the second opening needs no search at
all. A remembered link that stops loading is forgotten rather than
retried forever.

The page names the source under Play — "Found on AnimeKAI · Change" —
because a Play that silently picked a source is a Play the viewer cannot
question. Change forgets the pick and opens the source list.

Episodes, playback and download are asked of the source that was found,
not of the catalogue. The two places that read the route's provider for
that now read the resolved one.

The rails themselves come from the backend's new /catalogue/:kind/home,
so one cache serves every phone and the keys stay off the device.
The catalogue chips were small pills under three tall mode chips — a
different component that happened to be nearby — and picking one closed
the sheet and did nothing visible until the new home arrived, which on a
slow request reads as the app hanging.

They are mode chips now, with a sign, so the two rows read as one family
with one difference. And picking one plays the switch: the cover opens
from the chip in the catalogue's colour, draws its star, and lifts once
the catalogue has loaded — the same reason a mode has one, since the
whole home is replaced underneath either way.
The first live run found nothing, and quickly — no source had even been
asked. A TMDB card says `movie`; the providers say `movies`, or `tmdb`,
or `anime`. One letter, and the category filter excluded all 24.

The resolver chooses the candidates itself now: every source that plays
video and is not browse-only, decided on the mode enum rather than on a
string two systems spell differently. Manga sources are never asked — a
leg spent on one is a leg not spent on a source that might have it. The
category passed to the search is blank, so the filter does not get a
second chance to disagree.

Verified live: 24 of 24 sources searched, VidAPI answered in 0.33s, the
page loaded from it with "Found on VidAPI" under Play.
…ve Play

## The page

Opening an AniList title used to load the found source's page — a
scraped title, a scraped blurb, a scraped poster — and the record AniList
actually has for it went unused. It is AniList's page now: the words, the
cover, the score and where it ranks, the studio and what it was adapted
from, the episode count and when the next one lands, the characters with
their voice actors as the cast, AniList's recommendations as the related
titles. The source is fetched alongside, not after, and is only where the
episodes come from.

With no source found the page still shows, with "Find a source" in place
of Play. A title you cannot play yet is still one you can read about,
save and track.

TMDB titles keep the source's page for now; every TMDB-backed provider
already renders TMDB's record.

## The hand-off

Where the title came from and where it plays from moved above the Play
button, with both marks — the catalogue's own logo and the source's:
"AniList › AnimeHeaven · Change". Below the button it read as a footnote
to a decision already made; above, it is the decision. TMDB gets a mark
of its own for it.

## The pick

The first live run chose VidAPI's 2023 live-action ONE PIECE for
AniList's 1999 anime: same title, so the confidence threshold stopped the
search before an anime source could answer. Two rules now: the kind of
source has to fit the kind of catalogue (an anime source for AniList, a
film source for TMDB — a mismatch costs more than a slower answer gains),
and a year that is known on both sides and differs pulls the score down
hard. The search stops early only on an answer nothing argues with.

## Also

The mode glyphs are drawn now rather than sketched — a ring around the
play sign, a manga page with its diagonal cut and a speech balloon, an
open book — with a soft glow under the stroke and a bright tip where the
pen is. The source lists arrive row by row rather than all at once, using
the entrance the home rails already had. A catalogue switch plays the
same cover as a mode switch, in the catalogue's colour. The relations rail
was five points too short for a two-line title.
## Tracking

"Track on AniList" and "Track on MyAnimeList" were two rows in the
overflow menu that each opened a sheet, and neither said whether the
title was tracked, let alone how far along it was. They are one line per
connected tracker on the page now, under Play, with the real number on
it — "Watching 4/14" — and − + that write it back without leaving. A
title the tracker does not have yet says so and offers to add it; one it
has never been linked to offers to link it, through the sheet it always
used.

Adding to AniList had never worked from a fresh title: `addToList` tested
the entry state for null, and the state comes back for every title
AniList knows, on the list or not — so a title not yet on the list was
read as already there and nothing was written. It reads the flag now.

## The record

The AniList grid — studio, source, episodes, rank, status, format, tags —
was under the Play button and filled the page before the tabs. It is its
own tab now, first, and only a title that came with a record gets one.
The header keeps what is glanceable: the score joins the meta line, and
the next episode's countdown stands alone above Play.

## Also

The source's own logo now reaches the hand-off row — the search result
does not always carry it, the provider list always does. MyAnimeList's
update answers without the episode total, so the known one is kept
rather than "5/14" turning into "5" the moment it is touched.
The record on a detail page was AniList's shape — AniInfo — and a TMDB
page had nothing to put there. It is now RecordInfo: ids, score, next
episode, and a list of labelled facts, so the About tab is the same
widget for both and each catalogue fills it with what it knows. TMDB's
comes from the backend's catalogue detail (studio, network, seasons,
episodes, budget, box office, collection, status); the tagline goes
above the description.

Search with a catalogue selected went to the provider search with an id
the server had never heard of. It now goes to /catalogue/:kind/search,
in the provider search's shape, so the results page needs no changes.

Format words from AniList keep their initialisms: TV, OVA, ONA.
The cross-search already orders its legs by what this device saw last
time — but a mark is only made after a wait, so a dead server provider
was asked first, on the full budget, on every fresh install and every
six hours after that.

The server now keeps the provider harness's verdicts, and the store
pulls them at most once every six hours (/contents/providers/health).
A source the harness found dead starts at the back with the short
budget before this device has ever tried it. The device's own fresh
mark wins over the server's, so a source that recovered gets its place
back the first time it answers, not at the next scheduled run.

Verdicts older than four days are forgotten; a stalled workflow must
not keep sources at the back for good. Nothing about the search waits
on the fetch: the run that triggers it orders by what was known.
The catalogue chips in the source sheet and the chip on Home carried a
sparkle; the hand-off row on the detail page already carried the real
AniList and TMDB marks. Now all three do. In the switch animation a
brand mark cannot be traced as strokes the way the mode glyphs are, so
the pen draws a ring round it and the mark is wiped in behind the pen,
clockwise from the top — the same beat, with the actual logo in it.

Two things seen on the first TMDB pages: the anime trackers offered
themselves under a live-action series, and the source's mark went
missing on the second open. The tracking row now stays off a TMDB page
unless the title is animation, and the remembered link keeps the
source's image.
AniList is three shelves — anime, manga, light novels — so the readers
now have a catalogue the way Watch does: an AniList chip in the source
sheet for each mode, a home built from the shelf, search and genres
inside it, and a title resolved on the readers rather than the players.
Switching mode lands on the catalogue when the viewer was on one, or
when the mode has no source at all — an empty mode was a dead end.

AniList's page and list queries drop `type: ANIME`: a manga id under it
was a null Media, which read as "not on the list" for every manga. The
record carries chapters and volumes in place of episodes, the list entry
carries its id and score, and a save can set a score.

The trackers belong to AniList's catalogue only. A provider page or a
TMDB series has no certain id to key on, and "Track on AniList" under
one was a row that could only fail; MyAnimeList stays off manga pages
too, since the client here writes to the anime list.

Opening a title now blooms rather than slides: the page rises and fades
in while the artwork sharpens from a blur into place, and the header's
blocks land one beat after another under the poster.
The row moved the number by one and nothing else; status, score and
"take it off my list" still meant leaving for the tracker's site. A tap
on a listed row now opens a sheet with all of it: the status pills, the
progress with a long press to jump to the end or back to the start, a
score out of ten, and Remove. Every tap writes at once — a sheet with a
Save button loses its changes when it is swiped away — and a failed
write puts the value back and says so.

One sheet for both trackers, through a small editor each. AniList
reads "Reading" and "Re-reading" on a manga, where its CURRENT is the
same status with a different verb; MyAnimeList gains an id-based list
update so the page can write without a search hit in hand.
Before a word was typed the tab was a magnifying glass and a hint, then
a row of genre names once the source had any. Now it is what was
searched last, what is trending on the source or catalogue in use —
the home's own rail, not a second request — and the genres as a grid of
covers: the most popular title's on AniList, a known poster on TMDB,
a colour of its own where a source ships none. The tiles land in a
stagger like every other grid.

A catalogue's genres come from the catalogue: /contents/genres answered
400 for a catalogue id, on the search tab and on Home alike.
Three loose chips said "here are three buttons"; one track with a thumb
says "you are in one of three places". The thumb sits on the current
mode in its colour and slides to the one tapped — the sheet waits that
one beat before it closes, so the tap is answered on the sheet rather
than by the sheet vanishing — and each segment carries the glyph the
switch animation then draws, so the thing tapped and the thing that
fills the screen are the same thing.

The catalogues are cards now: mark, name, one line on what they hold.
"TMDB" to somebody who has never used it is three letters; "Movies &
series" against "Anime catalogue" is a choice.
The switch is now a drop landing rather than a hole growing: rings run
ahead of the reveal over the old screen, its edge carries a soft rim,
the glyph breathes while the cover holds and a ring leaves it each
breath, and one last wave crosses the new screen as the cover lifts.
The pulse runs only while there is a reload to wait for.

On the sheet: the segment the thumb reaches draws its glyph, taps
click, the catalogue cards carry three covers from their front page
fanned along the far edge (one small cached request each), the rows
say where a source comes from and what language it speaks, and the
list is headed "Sources · n".
The formatter that ships with Flutter 3.44 wraps arguments and places
trailing commas differently from the one these files were written under,
so every file touched since has arrived with a page of unrelated
reflow attached to it and every diff has been harder to read than the
change in it.

Nothing here is a change in behaviour: each of these 113 files is byte
for byte what `dart format` produces from the version already committed,
which is what makes it safe to take in one go and worth taking now
rather than a few files at a time forever.
## The switch

The middle of a mode switch was a stock glyph — a play sign, a page, a
book — with Sozo's own mark shrinking away behind it. The thing that
survived was the generic one. It is the mark now, written by a pen along
every stroke and then inked by a band of light travelling across it,
with the mode demoted to a badge on its corner.

Each mode also arrives in its own shape: Watch opens as an iris, Manga
cuts in on the diagonal its own glyph is built from, Novels opens
outwards from a vertical spine. One iris for all three made the
transition a template, and the three AniList shelves — which share a
name, a colour and a logo — produced covers that were pixel for pixel
identical. The cover names the shelf now, "ANILIST · MANGA".

It is also 140ms shorter than it was, because the pen was sized against
the beat rather than the other way round; the mark is read and measured
once per process instead of on the first switch, which is why the first
switch of a cold start used to be the one that did not animate; and the
exit no longer buys a screen-sized offscreen buffer on every frame.

## The wrong show

"Try another source" for *Return of the Blossoming Blade* offered *Blade
of the Immortal*, *K: Return of Kings*, *The Lord of the Rings: The
Return of the King*, and a KissKH entry called simply "Return" — which
scored a perfect 1.00, because the old formula divided shared words by
the SHORTER title and counted "of" and "the" as evidence.

Title matching is one tested module now, shared by the source switcher
and by the catalogue resolver, which had the same weakness pointed the
other way. All five wrong answers fall under the floor. The fix had to
survive its own opposite: a catalogue hands over "Bleach: Thousand-Year
Blood War" and a source carries "Bleach", so a title is also measured
against its own head — but only where a colon or a spaced dash puts one
there, which is why "Return" is still measured against the whole thing
and still rejected.

And because a matcher is always wrong sometimes, every row now says how
sure it is and carries "Wrong title?": search that one source yourself,
pick the right entry, and the choice is remembered. Sources that found
nothing are listed too, so one can be searched by hand rather than
silently left out.

"None of your sources has this title" was also the only sentence the app
had for four different situations, including having no reader installed
and having every source time out — both of which sent people to install
a source they already had. Each says what actually happened now.

## AniList manga and light novels

A manga or novel card fell past the AniList branch entirely, because the
gate compared against the anime catalogue by identity, so the record was
never asked for and the page was empty. The shelf is asked for its own
MediaType, a reader's search and its saved total use chapters where an
anime uses episodes, and the author credit is taken from a writing
credit rather than from whoever sorts first.

## What a source says it is

VidAPI was labelled RU. Nothing ever declared a language for a Sozo
cloud provider, so the app fell through to guessing from the hosting
domain, and vidapi.ru is the only one of the twenty-four where that
guess fires — and it is wrong, because VidAPI serves English. The
backend declares the language now, and the three screens that displayed
it three different ways agree.

## The desktop

The window is a citizen of its OS: its size and position survive a
restart and are clamped back onto a display that still exists, files
opened with Sozo reach the player on Windows and Linux, `sozo://` is
registered, a wide window gets the rail instead of the phone pill, and
Tab reaches things. The Linux tarball ships a .desktop entry and an
icon; the Windows installer stops calling every release 1.0.0; CI
compiles a desktop target instead of finding out on release day.

## Elsewhere

Posters answer a press; the browse loop has haptics; skeletons hold
still when the OS asks for less motion; the search landing builds the
tiles it shows rather than all of them; badges mirror in Arabic; the
player's keyboard map is a real one with a cheat sheet that only prints
what is actually bound. The source hub's filter row no longer lands on
top of the list mid-transition, no longer jumps 34 pixels when a search
narrows it, and is big enough to hit.

1030 tests, analyzer clean.
## "Try another source" is posters now

It was a list of source names, and a name is the one thing that does not
settle the question the sheet exists to answer — is this the show I was
watching. Four sources answering with the same words are identical in a
list and are told apart instantly by their artwork, and a source that has
quietly matched the wrong series is usually obvious from the cover long
before it is obvious from the title.

Three across on a phone, four or more on a tablet or a desktop window —
sized by how wide a tile may be rather than by a column count, so there
is one layout rather than two. "Wrong title?" keeps its own 48dp target
in the corner rather than the tile's own tap, which is still "play this".

A test caught the thing that makes that kind of layout fail: the
full-bleed play surface was painted last and swallowed the corner
control, so the escape hatch was unreachable by touch while looking
perfectly fine.

## Cantonese

廣東話 is an interface language now — 1825 strings, in Traditional
characters, written as Cantonese rather than as Standard Written Chinese:
睇 not 看, 嘅 not 的, 冇, 咗, 搵, 我嘅清單.

Three things had to be true for it to work at all:

`yue` is not a locale Flutter ships. `GlobalMaterialLocalizations` has a
fixed list and Cantonese is not on it, so nothing resolved
`MaterialLocalizations` and every AppBar and Scaffold in the app threw
"No MaterialLocalizations found" the moment the language was chosen.
Cantonese borrows Traditional Chinese for the framework's own strings —
a date picker that says 取消 is correct in Cantonese. Every widget test
had passed through this because a test that names no locale falls back
to the English defaults, so the suite had never asked the question; it
asks now.

The subtitle translator takes its target language from the interface
language when nobody has chosen one. None of Azure, DeepL or Google
accepts `yue`, so a Cantonese reader would have had subtitles that
silently never arrived. The fallback checks the target is one the
translators actually offer, and sends a Cantonese reader Chinese.

And a source tagged `yue` now reads "Cantonese" in the language filter
instead of "YUE", alongside the Chinese variants that were already there.

1037 tests, analyzer clean, verified on a device.
Switching from 廣東話 to English left a Profile tab reading "Downloads",
"Activity" and "CONTENT" beside 連接, 來源 and 睇片. Half a page in each
language reads as a broken app rather than a half-finished translation.

`easy_localization`'s `.tr()` reads a singleton and registers no
dependency on an InheritedWidget, so changing the language marks nothing
dirty. Which words a screen ends up with then depends on something it
should not depend on: WHERE the string was computed. A row written as
`SettingsNavTile(title: 'profile.downloads'.tr())` has its words computed
in its PARENT's build, and the parent does not re-run — while a tile that
calls `.tr()` inside its own build rebuilds for its own reasons and comes
back in the new language. That is exactly the split the screenshot shows.

The tab pages are keyed on the language now, so a switch tears them down
and builds them again and the whole page speaks one language.

Keyed there, below the Navigator, and deliberately not around the app: a
key on the MaterialApp rebuilds the Router around a GoRouter that is a
single long-lived instance holding a GlobalKey, which puts that key in two
live trees at once. That asserts — "element._lifecycleState ==
_ElementLifecycle.inactive': is not true" — on the first switch, which is
what happened when it was tried there first.

The cost is that a tab's scroll position does not survive a language
change. That is the right trade for the app's least frequent setting.
## The tracker row

The one thing on the row you are meant to press was a bare `Text` in the
accent colour — no padding, no target of its own, six pixels from the edge
of the card, and the smallest thing on a line that was itself about 34px
tall. It read as a label that had been squeezed in. It is a button now,
with a border, a chevron and room around it; the line is roomier; the −
and + targets go from 36dp to 44; and the counter box grows with the text
scaler instead of clipping at it, because a fixed 52px fits "12/24" and
not "121/220".

## "Releasing now" answered with a 404

A catalogue's rails have nowhere to page into, and the backend says so by
sending a null `viewAll`. That arrived in the app as an empty type and
slug, the section header stayed a button anyway, and tapping it pushed
`/view-all` with nothing in it. A header with no destination is not a
header with a destination, so it is no longer drawn as one — no chevron,
no tap, no haptic.

## And the screen it landed on printed the exception

Under a headline that said "check your connection" sat three sentences of
Dio explaining what `RequestOptions.validateStatus` is. Neither half was
true or readable. The view classifies the failure now — a 404 says the
thing is gone, a 403 says it is blocked, and only a real connection
failure says the connection failed — and the raw line stays underneath,
clamped to two lines, where it still helps a bug report.

That classification already existed twice: `SourceFailure` did it for the
sentence, and `home_state_views` did a slice of it again for the icon,
with a comment saying the icon belongs on SourceFailure and that the file
was not theirs to change. It is now: `SourceFailureKind` carries the
answer and both screens read it.

## The switch stopped glowing

Three rings running out of the tap, a blurred rim on the reveal, a ring
leaving the mark on every breath, a last wave on the way out, and a radial
bloom behind it all. Radiating neon rings are the house style of every
machine-made "tech" animation there is, and they said nothing about Sozo
or about which mode you had asked for.

What is left says more with less: the boundary of the shape that is
actually opening, drawn as one crisp accent line. It is the manga panel's
own diagonal, the book's own spine, the iris's own circle — so the edge IS
the mode, and it is a line rather than a glow, which is the difference
between something drawn and something lit. The ground behind it is a flat
wash leaning the way the press came from, not a bloom.

1037 tests, analyzer clean, verified on a device.
## The macOS build was not building

Not our Dart: Xcode's current SDK rejects any MACOSX_DEPLOYMENT_TARGET
under 12.0 as an error, once per pod, and the pods declare 10.11 through
11.0. So "the desktop code is correct by reading" was the only claim
anyone could make about it. The Podfile asks for 12.0 and the post_install
hook forces it per target, because a `platform` line does not override a
podspec that names its own version — and a single unpinned pod fails the
whole build.

This drops macOS 11 Big Sur, out of security support since 2023. The
alternative is a desktop target that cannot be compiled.

It builds now, and it runs: the window remembered a 1512x897 frame across
a quit and came back in it, which is the first time any of the window
work has been executed rather than read.

## The AniList library shows a reader's lists

`mediaList` was pinned to ANIME, so somebody tracking manga on AniList
opened the library and found none of it. The query takes a MediaType; the
shelves are Anime / Manga / Novels, which is two queries, because AniList
has no NOVEL type — a light novel is MANGA with format NOVEL, the same
split `Catalogue` already makes.

The reason this was deferred is that the UI counts in episodes. It counts
in `totalUnits` now — `episodes ?? chapters` — so a manga gets a real
progress bar instead of a permanent zero, finishing the last chapter flips
it to COMPLETED, and the sheet opened from a library card says chapters
rather than captioning chapters as episodes watched.

An anime-only account gets a pixel-identical screen and one request; the
second request is opt-in per controller, so the calendar and the reminders
do not pay for data they drop.

## Reading a chapter reports it

It reported nothing, anywhere — the only progress-reporting site in the
app was the player. Now a chapter read to its end reports to AniList,
following the player's answers to the same questions: a threshold rather
than the first frame, a ledger so one chapter is one event, and a write
that can fail without the reader ever knowing.

The first attempt reported nothing in the mode almost everyone reads in.
Continuous is the default, and there `_currentPage` is whatever straddles
the TOP of the viewport — which the last page never reaches, so the
chapter could never finish. It tracks a high-water mark of pages whose
TRAILING edge has been inside the viewport, so a flick that brings the
last page's first centimetre on screen does not count as reading it.

## A light novel can find a source, and is told when it is a guess

Every `mn:` reader was classified as manga, so the Novels catalogue
filtered its candidates down to nothing and Play could never resolve.
Readers are asked what they are now, and where nothing installed reads
novels the search falls back to manga sources — marked, in the same
vocabulary the source switcher uses for an uncertain match, saying it may
be the adaptation rather than the novel.

That mark was added and then read by nothing, which made the app less
honest than before the change rather than more. It reaches the screen.

## iOS keeps its back gesture, and the whole gesture

`bloomPage` already handed iOS the platform page so the edge swipe
survives. Half the arrival animation did not get the memo: the poster's
bloom and the header's scrims both ride `ModalRoute.animation`, which on a
Cupertino route IS the swipe — so dragging back blurred the poster and
faded the gradients under the thumb, on a drag the user could still
abandon. One getter decides both, so they cannot drift apart again.

## Cantonese, edited rather than generated

An editing pass over the 1825 strings fixed Mandarin that had slipped in,
inconsistent terminology across the eight slices it was written in, and
buttons that had become sentences. "Connections" is the account AND device
screen, so it is named for both rather than for half of it.

1175 tests, analyzer clean.
## Search was showing a section called HOMEPAGE

The heading over the poster shelf on the Search tab was
`'${rail.label} · ${catalogue}'` — `rail.label` being whatever the SOURCE
called its first home row. The source that was selected calls it
"Homepage", so the Search screen had a section named after a page. Other
sources say "Trending now", or "Latest", or nothing, so the heading was a
lottery on whichever source happened to be current.

It is the app's own sentence now — "Popular on AnimePahe", or "Browse
AnimePahe" when the shelf it borrowed is not the popularity row, because
then the app does not know what the row is and should not claim to. The
source's label is still read, and never printed: it is good evidence for
which row won, and a scraped untranslated string is not a heading.

Plain sources are named too. The old heading named only catalogues, so on
an ordinary scraper — the exact case that produced "HOMEPAGE" — it carried
no source name at all.

## The player's bottom row carried everything

Eleven controls in one row against the seek bar, pushed to one edge, while
a landscape top bar sat half empty.

The row is centred. The split is by what a control is ABOUT: the bottom
row answers "what am I watching" — speed, server, quality, fit, subtitles,
episodes — and Cast, Picture-in-picture, Sleep and Watch Party answer
"what is this session doing", so in landscape they ride up to the top bar
with the Lock, as many as it has room for.

Nothing is stored. The move is about which way the phone is held and the
saved arrangement has no orientation in it; writing it back would rewrite
the viewer's own choice every time they turned the device. A control the
viewer placed themselves, or hid, is left exactly where they put it.

The ceiling for that is a new number: six is a PORTRAIT ceiling, derived
from the narrowest phone this ships to, and a phone held sideways has
about twice the room. The editor and the saved-layout overflow keep using
the portrait one, or a rotation would push a control into `hidden` — and
hiding is a decision the viewer makes, not one a rotation makes for them.

## The icons

They read as thin because the bar mixed outlined and filled glyphs:
`Icons.cast_rounded` sat beside `Icons.subtitles_outlined`. Everything is
the filled `_rounded` variant now, 18pt to 20, and the layout editor's own
icon map was swapped to match — it had drifted on eight controls, so a
control wore one face in the editor and another on screen.

And the row in the complaint got the fix it was missing: the landscape
bottom row is `_BottomTextButton`, which had no background at all, only a
drop shadow. Over a bright frame that is exactly the "thin, low quality"
the icon buttons beside it had already been given a disc to solve. It has
the same disc now, shaped for a label.

1201 tests, analyzer clean.
The Search tab's "Nothing to browse here" is a claim about what the
current source HAS, and it was drawn while the home was still in flight —
so a slow source put a magnifying glass and that sentence on screen for as
long as it took to answer, then replaced them with a shelf of posters.

Suppressing it until the home resolved was worse: the whole screen below
the chips went blank for the same second, which reads as a broken tab
rather than a slow one. Caught on a device, not in the tests.

So: a shape while it is loading, the sentence only once there is something
true to say. The skeleton is the heading and four posters at the sizes the
real rail uses, so nothing jumps when the answer lands.

1201 tests, analyzer clean, verified on a device.
Five failures that all end the same way: something went wrong off-screen
and the app quietly lost work the viewer had already done.

A refresh token that could not be SENT was treated as a refresh token
that had been REFUSED. `_performRefresh` caught everything and returned
null, so a request made in a tunnel expired the session — and
`clearLocalSession` deletes history, lists, tracker links and the streak.
Only a 4xx from the server is a refusal now.

The player never wrote a resume point during a first continuous watch.
`_scheduleHistorySave` armed a ONE-SHOT five-second timer whose callback
hit the ten-second floor in `_saveHistory`, wrote nothing, and was never
re-armed; the only other save was in `dispose`. Android does not run
dispose when it kills an app for memory, and neither does a crash or a
force-quit. Now it ticks, and backgrounding and detaching both flush —
the two callbacks that actually precede being killed, and the only ones
that cover PiP and the desktop, where playback is deliberately not paused.

Both retries rebuilt the controller from zero. Losing the network
mid-episode and recovering therefore restarted the episode, which is the
exact case a retry exists to survive. Both now read the position before
the teardown and pass it back. Live streams still start at the edge.

The Search tab classified failures with its own three-way copy of a
classifier that already existed, and that copy did not know what a 404,
a Cloudflare block or an out-of-date extension were — they all arrived
as "Search failed" over a Java stack line. It now uses SourceFailure,
which knows seven kinds, and SourceFailure has been taught the Dart and
Dio half of the vocabulary it was missing, so being offline is no longer
an unrecognised error printed verbatim.

A second page that failed to load was dropped on the floor: the spinner
appeared, vanished, and no row and no reason followed. Worse, the scroll
listener fires on every frame near the bottom, so a source that was down
turned one flick of the thumb into an unbounded stream of failing
requests. The failure is now reported in the footer with its own retry,
and the automatic attempts stop until that retry is pressed.

The threshold that decides whether a viewing is worth recording moved
into WatchProgress beside the rules it belongs with. The player gates are
source-reading rather than widget-driving, and say why in the file: every
defect here is a MISSING call, and a widget test for one of those passes
for the wrong reason more often than it catches anything. Each gate was
checked by reintroducing the bug it exists for.

1226 tests, analyzer clean.
Incognito is one promise spread across a dozen unrelated write paths, and
it is only ever as true as the least careful of them. Three were leaking.

Every query typed in incognito was written to the search recents — which
the idle Search screen draws, so leaving the player and going back to
Search listed the private session on the screen. Every Live TV channel
opened was pushed onto the recent-channels row on the home screen. And a
minute into playback the app pinged the streak endpoint, writing
"watched today" onto the account: the one record of a private session the
viewer cannot go and clear, because it is not on their device.

All three are now enforced at the write rather than at the call site, the
way HistoryService.save already was, because a guarantee that depends on
every future caller remembering to ask is not a guarantee.

Separately: both trackers gave up on a title because the network did.
`findExactMatch` returned null both when AniList answered with nothing
and when the request never landed, and `_resolveMediaId` fed both into
`_autoMatchFailed` — a set consulted BEFORE any lookup is attempted. One
failed lookup in a tunnel therefore stopped that title auto-linking for
the life of the process, however good the connection got afterwards. The
matcher's own doc says being unlinked is "recoverable"; this was the code
that made it not.

TrackerLookup names the three outcomes so a caller has to say which it is
handling, and only a settled miss — the catalogue answered, and has
nothing — is remembered. MAL asks two catalogues and now gives up only
when both actually replied. The optimisation the set exists for is
unchanged and tested: a real miss is still paid for exactly once.

SourceFailure is Equatable, so a bloc carrying one in its state compares
by value rather than by identity.

The incognito gates each assert the ordinary write still happens —
"records nothing, ever" would pass a leak test and break the feature —
and every guard was checked by removing it.

1247 tests, analyzer clean.
`SearchState.genresFailed` was computed in the bloc, carried on the state
and compared in `props` — and read by nothing at all. When the genre call
failed the Categories section simply vanished: on a device with recent
searches that removed the only way to browse the source, with no reason
shown and no way to ask again, on a screen that still looked complete. It
now says so in one quiet row with its own retry, and the row is absent
while the call is still in flight — a failure notice over a request that
has not come back is the complaint that started this audit.

WatchStatsStore refused any stretch longer than ten minutes, reasoning
that "a jump of hours is a clock change or a resumed process". Its only
caller measures with a Stopwatch, which is monotonic, so neither can
reach it. What the rule actually did was discard the longest viewings
whole: with the player's save tick broken, a film watched straight
through arrived as one delta of two hours, failed the test, and counted
for nothing — the statistics page read zero for exactly the sessions it
exists to show. Long stretches are now clamped rather than dropped, at
four hours: longer than any film, short enough that a caller passing
nonsense cannot add a week. A long stretch still happens legitimately
when the platform suspends timers during background playback.

Both gates were checked by reintroducing the bug they exist for.

1253 tests, analyzer clean.
Five on-device search paths — CloudStream, Aniyomi, Manga, Mangayomi and
the JS runtime — had no timeout at all. The backend path goes through
Dio, which has connect, send and receive budgets; these had nothing. A
plugin whose search() never returns, and there are several ways to get
one (a dead host, a Cloudflare challenge page that never resolves, a
socket the plugin opened with no read timeout of its own), left the
Search tab on its skeleton indefinitely: no results, no empty state, no
error, and no way out but backing off the screen.

They now share CrossSearchEngine.channelTimeout rather than getting a
second number invented here, because cross-search reaches the same
extensions and the two would otherwise disagree about whether a source
works. A host that never replied gets its own message: that is not the
same as one that replied with a failure, and only the first is worth
suggesting another source for.

PluginHost.searchJson caught every throwable from api.search(), logged
it, and returned an empty item list — which the app cannot tell from a
source that genuinely has nothing matching the query. So a CloudStream
plugin that crashed produced "no results" and spelling suggestions for a
search that never ran. getMainPageJson, twenty lines above it, already
solved exactly this with an `error` field and a comment saying why;
Aniyomi and Manga both set one on their search too. CloudStream was the
one host that did not. The Dart side has read the field all along.

1256 tests, analyzer clean.
…send

History is keyed per EPISODE; Continue watching and the History screen
both collapse it to one card per TITLE. The cap counted rows and kept
fifty. So fifty episodes of one series — under two cours of a weekly
anime — filled the entire store with that series, evicted every other
title, and left a home screen with one card on it.

The budget is now 1500 rows, which is well under a megabyte and makes
per-episode rows no longer scarce, and the trim knows which row is a
title's card and takes it last: episode 3 of a series somebody is sixty
episodes into is the cheapest thing in the store, because losing it costs
a tick on an episode list and nothing else. A second pass takes whole
titles when every remaining row is a card, so the rule terminates even
for a store of a thousand one-off films. The decision is a pure function
with its own tests; the service just deletes what it is told to.

The comment box cleared the instant Send was pressed — before the request
was even issued, because the panel adds an event to the bloc and returns.
When the write failed, a snackbar said so over an empty box and a comment
somebody had spent a minute writing was gone. The text is now held until
the bloc reports the write landed, and put back if it did not — with the
edit target and reply target, because restoring the words into a box that
had forgotten what they were for would post them as a new top-level
comment. CommentsEdit reports `submitting` like create already did, which
is both the spinner it should always have drawn and how the panel knows
an edit has landed.

Every gate was checked by reintroducing the bug it exists for.

1267 tests, analyzer clean.
The reported bug, found: one unlucky cold start permanently moved the
viewer's source to VidAPI, and nothing said so.

`_resolveAndPersistProvider`'s online branch treated "the saved source is
not in the list" as proof the choice was gone, and wrote `providers.first.id`
— vidapi — straight over it. But that list is the backend's plus whatever
the four on-device hosts managed to enumerate on this launch, and every one
of those helpers ends in `catch (_) {}`: a platform channel not ready yet, a
host process that restarted, an extension store still loading, and the
user's CloudStream or Aniyomi source simply is not there. It now parks the
saved id exactly as the outage branch already did, so the restore block puts
them back the moment the host answers again.

Three more ways a source got stuck, all in the health store:

A source marked broken is asked again on a four-second leash. For an
extension host that is less than the APK download it was punished for not
finishing — so it timed out again, and that timeout refreshed the record and
pushed the six-hour TTL forward. Search once an hour and the mark never
expired. A failure under a shortened budget now leaves the record alone: it
is still broken, it just does not get to renew its own sentence.

The leash also applied to the Retry button beside a failed source — which
only ever appears beside a source that just failed, so every retry ran on the
penalty and confirmed the failure it inherited — and to "Test this source",
the one tool for finding out whether a source is really dead. Both now name
one source deliberately and get its honest budget.

And JS-runtime sources were given the ten-second default in cross-search
although the runtime needs the same head start as the other on-device kinds,
so a source that worked when selected alone timed out in all-source search
and was marked broken by it.

Search also showed no Categories at all for every cs:/an:/mn: source — the
large majority of the 372 installed — although Home has drawn them for a long
time from the same channels. It fetches them now, and a genre tile browses
through the host rather than asking the backend about an extension's own slug.
Mangayomi still has none, because it exposes filters rather than a flat list.

"Retry all" fanned out with Future.wait over every failed leg, up to 60
concurrent searches each able to boot a WebView or dex-load an APK — around
the engine's own pool of five. Bounded to that pool.

The detail page's three stacked rows now share one shell: they disagreed on
radius, padding, border and text size, and ended in three different kinds of
control — bare accent text on the source row against brand-tinted pills on
the trackers, where MyAnimeList's darker blue read as disabled directly
beneath AniList's enabled one. The brand stays on the logo.

1273 tests, analyzer clean.
A JS extractor that returned null fell through to the backend; one that
THREW returned a hard failure. Same thing to the reader, opposite
treatment — so a Cloudflare challenge that survived the retry, a CDN
hiccup fetching the extractor, or an extractor with no search() at all
turned the Search tab red for a provider the backend could have answered.
ProviderManager already does the opposite for these same providers when
resolving media: it logs and falls back. Now so does search, and if the
backend fails too the extractor's reason wins, because it is the specific
one and the one the reader can act on.

Three places changed the current source by writing straight to storage —
the Shorts tab twice, and the deep-link handler — because ProviderBloc is
registered as a factory and they have no live instance to tell. So the
source changed app-wide and nothing heard: the picker still named the old
one, Home kept its rows, and Search kept the previous source's genre grid,
whose tiles then browsed the NEW source with the OLD source's slugs. The
notification now lives on the write in HiveService, where it cannot be
forgotten, and the shell listens for it as well as to the bloc.

Pressing the keyboard's Search key copied the existing criteria, so an
active genre survived into a text search that cannot carry one — `_fetch`
prefers the text and drops the genre — leaving a filter chip on over
results it had no part in producing. Typing the same query and waiting for
the debounce did the right thing. Two routes to one search, two answers.

Suggestions were only emitted when non-empty, so a query with none left
the previous query's in place and "Did you mean" offered titles for
something the reader had moved on from.

And the suggestion service asked AniList's ANIME index and two hard-coded
film providers whatever mode the app was in. On a manga or novel source
that made the one recovery affordance on an empty screen a list of titles
the source cannot carry, each guaranteed to come back empty, and spent a
round trip on a film provider to produce it. It now follows the mode —
querying MANGA for novels too, because AniList has no NOVEL type and
asking for one would have left novel mode with no suggestions at all — and
the cache is keyed by mode so a video answer is not served back after the
switch.

1282 tests, analyzer clean.
Every genre tile was washed in a hue taken from a nine-colour wheel keyed
on its position — red, orange, yellow, green, teal, blue, purple — laid
over real artwork, and the Home row did the same thing in the accent red.
A tint that comes from the tile's INDEX means nothing: "Action" was red
because it happened to be first, and became orange the moment a source
listed one genre before it. Colour dressed as information. Over a
photograph it also fought the photograph, leaving the card neither the
artwork's colour nor the app's.

The scrim is black now, and the covers supply all the colour — which is
what makes a wall of them read as a catalogue rather than a swatch book.
It also starts clear and finishes heavier than before, because these
covers are POSTERS with the film's own title painted across them: "Action"
sat on top of the word JOHN WICK at a similar size and weight and the eye
could not tell which of the two was the label.

Size, too. At two columns and 1.85 each genre was a landscape card the
size of a small poster, so a source with forty-one of them filled several
screens — a browsing aid taking more room than the thing it helps you
browse. Three across at 1.5, and the Home row's cards a little wider and
shorter.

The section headings were 11pt letter-spaced grey small-caps. That is how
a form labels a field: it reads as metadata ABOUT the content rather than
as the name OF it, and on a screen of posters it was the quietest thing
there. White, 18pt, sentence case, with the space above that a row title
needs — and the rail's own trailing gap removed, since the two were
stacking into a band of empty screen wide enough to read as a missing
section.

The rail's height was a flat 214 where a 118-wide card needs about 223.
The caption is the inflexible part of that card, so the nine-point
shortfall came out of the POSTER — which stopped being 2:3 and cropped
further at every text size above the default. It is derived from the card's
own arithmetic now.

And a genre tile answers a press the way a poster does — a dip — instead
of spreading a coloured Material splash from the touch point.

1282 tests, analyzer clean.
…fails

"Try another source" offered every video source in the app on a manga
title. `category` holds a content category for a backend provider
(`anime`, `movies`, `tmdb`) but an ECOSYSTEM for an extension
(`cloudstream`, `aniyomi`, `manga`, `mangayomi`), and the rule waved
through any pair where either side was an ecosystem — with `manga` being
BOTH. So on a manga the test short-circuited to true and the filter did
nothing. On a catalogue title it was worse: providerCategory looks a
catalogue up in the backend list, does not find it and returns '', which
the first line waved through as well. Two routes to no filtering, on
exactly the titles where it matters.

The reasoning was also wrong on its own terms: an ecosystem says exactly
what a source carries — CloudStream and Aniyomi are video, MangaHost and
Mangayomi are read. `String.contentMode` already encodes all of it,
catalogues and Mangayomi's novel index included, so the comparison is made
on what the app already knows instead of a string that means two things.
Manga and novel stay one group, because a light novel is routinely carried
by a comic source and the catalogue resolver already leans on that.

Play on a source in that sheet looked like it did nothing. It reported the
failure with a snackbar — which a modal bottom sheet draws OVER, so the
one thing explaining why nothing happened was underneath the sheet being
looked at. The reason now appears on the tile that failed, where the
finger already is.

Speed and aspect ratio each opened a full sheet over the video — in
landscape, over the thing being watched — to pick from six values and
three. Nine taps in ten mean "one step". Tap now takes the step and a
toast says where it landed; long-press still opens the list.

And four player glyphs were frames rather than shapes — a hollow phone
outline and a hollow picture-in-picture box next to a solid gear and a
solid padlock, which is what "the icons are empty inside" was about. The
episode list, subtitles, rotate and PiP buttons now use solid glyphs.

1286 tests, analyzer clean. The kind filter was checked by disabling it.
The score was the same minus/plus stepper as the episode counter directly
above it, reading "Not scored" or "★ 7 / 10". Two problems: putting 8/10
on a title took eight taps, and the two rows were visually identical, so
the thing that counts episodes and the thing that rates the show looked
like one control repeated. Nothing about it said "rating". Ten stars now,
tapped directly, and tapping the star you are already on clears it —
which is the only way back to not-scored.

The About tab was eight identical grey cards, each an uppercase grey label
over a value, so nothing distinguished the studio from the rank from the
status and finding one meant reading all of them. Each fact now carries
the icon of the KIND of thing it is, so it can be found by shape, and
status — the one fact whose value is a STATE rather than a value — is
coloured by that state: green still airing, grey finished, red cancelled
or on hiatus, amber not yet started. An unrecognised status stays neutral,
because a wrong colour states something about the title that nothing
established, and no other fact is coloured at all, because colour used as
decoration stops being able to mean anything.

The installable-source catalogue was titled "Add source" and every row on
it carried a button called "Add source" — the same translation key. The
title said nothing about what the list was and the screen read as eight
identical calls to action. The title names the list now and the rows name
the act: Add, Use, Added.

Adult manga sources default to shown. Off by default hid a large part of
the installable catalogue behind a switch nobody knew to look for: a
source searched for by name simply was not there, with nothing to say it
had been filtered. The toggle stays.

1293 tests, analyzer clean.
…mirrors left

A 28-finding audit, verified adversarially. This lands the freezing, the
rapid-tap races and the worst of the loading failures.

THE SOURCES SCREEN. Typing one character re-filtered and re-sorted the
installed set for all THREE tabs — `_tabList` is a method call inside a
list literal, so TabBarView's laziness cannot defer work that has already
happened — because the needle was part of the memo key and therefore a
guaranteed cache miss. The needle was also in the PageStorageKey, so every
character destroyed and rebuilt all three lists: the scroll position reset
to zero and roughly three dozen entrance AnimationControllers and timers
started again, which is the list "flashing" while you type. The needle is
now neither: the expensive half is memoised per (providers, mode,
languages) and the needle applied as a linear `where` over it, because a
substring filter cannot change the ORDER of what survives it. The field is
debounced at 200ms, the pattern providers_page already used.

`indexLetterOf` compiled a fresh RegExp on every call and is called twice
per comparison — about nine thousand identical RegExp constructions to
sort a thousand names. Hoisted: a measured 1000-name pass went from 3.16ms
to 1.73ms, 45% off every sort on that screen, for one line.

`SourceHealthStore._load` rebuilt a copy of the whole health map on EVERY
`statusOf`, and `order()` calls it once per source while `budgetFor` calls
it again per leg — O(n) per item is O(n²) per frame. Cached, statically,
because the store is constructed in two places and both read the same key.

`providers_page` evaluated `displayLang` for every provider on every build
even with no language selected, and that walks the whole name-hint table
per source. `langMatches` returns true for everything in that case, so the
cheap question is asked first; the guess is also memoised for when a
filter IS on.

RAPID TAPPING. "Retry all" had no in-flight guard, so three impatient taps
put fifteen concurrent extension searches on a device already slow enough
to invite them. The catalogue's "Use" awaited a Hive write before popping,
so two taps popped the page underneath as well. "Solve Cloudflare" awaited
a platform channel before pushing, so two taps stacked two solver routes —
solving the visible one returned to another copy of itself, which is a
large part of "Cloudflare does not work well". And the solver's poll timer
popped without checking its route was still current, taking whatever was
underneath with it.

LOADING. RetryPolicy — which has tests — had no caller at all: the page
hand-rolled the same decision and got the last case wrong, so a 403 or 404
ended playback with untried mirrors left. That is precisely the case where
another mirror is the answer, because the file is gone from that server
and not from all of them.

Both retries replayed `_videoUrl`, which is the post-sniff, post-proxy
url. For a movie behind an extractor directive that re-ran the WebView
sniff on a url the sniff itself had produced: the "page" is a video file,
nothing matches, and the retry failed for a reason unrelated to the first
failure. The url that produced the stream is now kept beside it.

The source-reading gate helper was truncating method bodies at a
multi-line parameter list, which made assertions about those methods
vacuous. Fixed, and every gate here was checked by reintroducing its bug.

1297 tests, analyzer clean.
Four breaks, each of which made the other three pointless.

The player's "Solve Cloudflare" button tested `isCloudflareError` against
`_errorMessage` — which by then is a translated PlaybackFaultKind sentence
or a humanised line, so the words the classifier keys on are gone. It
never matched. The one screen where a challenge is actually visible to the
viewer was the one screen that could not offer to clear it. The raw
failure is kept beside the readable one now and the test uses that.

Playback headers never carried the cookie at all. CfBypassService can earn
a clearance headlessly, the interactive solver can earn one in front of
the viewer, and both the Dio client and the JS runtime send the result —
but the player, which fetches a segment every few seconds for an hour,
sent nothing. Solving a challenge changed nothing about playback. The jar
travels whole, because Cloudflare pairs cf_clearance with the `__cf_bm`
and `_cfuvid` it was issued alongside and the clearance alone is refused.
Loopback still gets nothing: that is our own proxy and already carries the
upstream headers.

The WebView sniffer ran under a hard-coded `Chrome/125.0.0.0` on a Samsung
string. Cloudflare binds cf_clearance to the exact agent that earned it,
and a managed challenge compares what the header claims against what the
engine actually is — so a fixed version both invalidated whatever the jar
held and could never clear a challenge of its own. core/network/user_agent
exists to be the single answer to this, read from the device; the sniffer
was the last place inventing its own, and those headers travel on to the
player, so the mismatch outlived the sniff.

And the providers page disabled the solve for `my:` — the one ecosystem
that needs it most, since Mangayomi runs in the app's own JS runtime
rather than behind an Android host and `requestCloudflareSolve` has an
entire separate branch for it. That branch was unreachable: a Mangayomi
source stuck behind a challenge had no way out.

1306 tests, analyzer clean.
… leg

`planLegs` runs the health store's `order()` over the entire selected set,
and the page called it three times per build — twice through `_capped`,
which two separate widgets read, and once more for the count beside it.
That page rebuilds on every `notifyListeners()` from the controller, which
is every leg that arrives and every flush tick, and again on every frame
of the keyboard animation. Three sorts of several hundred sources per
frame to answer a question whose inputs had not changed. Memoised per
(scope, provider list).

The source-status list force-opens the moment any leg fails — which, on a
device whose extension sources are cold, is immediately — and built one
ListTile per leg and per pending source, eagerly, inside a
SliverToBoxAdapter, which is not lazy. A hand-picked scope is uncapped, so
that is as many rows as sources selected, rebuilt on every one of those
notifications. Past twenty rows it is now a bounded, virtualised list, and
the rows are built in the delegate rather than collected into a list first
— otherwise the allocation stays O(n) however little of it is laid out.
Under twenty it is unchanged, because a short list in an ExpansionTile is
cheaper than a nested scrollable.

1306 tests, analyzer clean.
Renditions came off the master playlist as bare heights, and a master
routinely carries the same resolution two or three times at different
bitrates. Rendered raw that is "1080p, 1080p, 720p, 720p" — rows identical
on screen that are not the same file, with nothing to say which one is
being chosen or which is actually better. A rendition now carries its
BANDWIDTH, the list keeps one row per height, and the row reads
"1080p · 4.0 Mbps". AVERAGE-BANDWIDTH contains the substring BANDWIDTH, so
the pattern refuses it explicitly — matching the average would rank two
renditions by the wrong number. A packager that declares no bitrate gets
just the height, never "1080p · 0 Mbps".

The ordering change reaches further than the sheet. `_pickVariant` in the
downloader takes `variants.first` — "nobody downloads a film in order to
watch the smallest copy of it" — and within a height that used to be
whichever the packager happened to list first. Packagers commonly list
the LOWEST bitrate first, because a client is meant to start
conservatively and adapt. So a 1080p download was routinely the worse of
the two 1080p encodes on offer. Sorting by bitrate within a height makes
"the first one" mean "the best one" everywhere that phrase is relied on.

The source rows showed `quality` — whatever the provider chose to call the
mirror, which is "SUB HLS", "FHD" or "Server 2" as often as a resolution.
They now lead with the resolution when it can be derived at all and keep
the provider's words underneath, where they still do the job of telling
two 1080p mirrors apart. Not repeated when the label already IS the
resolution, which is common.

And that derived height now travels into the download itself, so the
downloads list stops saying "quality unknown" about a file whose quality
was written on the row that was tapped.

1318 tests, analyzer clean. The ordering was checked by removing it.
…ents

The AnimeAV1 report, and it was an app bug after all — I said earlier it
was not, on the strength of a test that was missing a variable.

The symptom: the master playlist returned 200 and every segment returned a
Cloudflare 403, identically, whatever User-Agent, Referer or cookie jar
was sent. Measured against the live host one header at a time, the single
one that cleared it was `Sec-Fetch-Site`. Not the agent, not the referer,
not a clearance cookie — fetch metadata, which every real browser attaches
to every subresource request and no bare HTTP client sends at all. The
player was being refused on a request the same device's browser is allowed
to make, which is why the source plays in a browser and would not play
here.

The value is computed the way a browser computes it rather than pinned to
`same-origin`. The header is a CLAIM about the relationship between the
page and the request; a CDN on another host is genuinely cross-site, and
stating otherwise is the same class of lie as the hard-coded User-Agent
the previous commit removed — exactly what these rules exist to catch. A
source that sets any Sec-Fetch header itself is left alone entirely, all
or nothing: half-overwriting the set is its own fingerprint.

Pure, in core/network, with its own tests — including the registrable-
domain approximation, which is wrong only for multi-part public suffixes
and is not worth carrying a public-suffix list to fix, since being wrong
costs a header a server may ignore.

1327 tests, analyzer clean.
The error screen jumped straight to "Try another source", which searches
every other provider in the app — a slow and uncertain thing to do when
the title in front of you is carried on four servers and only the one that
was tried has failed. A title with several servers had no way to pick a
different one from that screen at all: the control exists, on the bar, and
the bar is not on screen when playback never started.

The server list now comes first, and only when there is more than one to
choose from.

And the Sources hub's second pill said "Add source" and flipped the same
screen over to the installable catalogue — a second list, on the same
page, under the same title, with its own search and its own chips. Two
lists behind one pair of pills is where that screen became confusing, and
it buried the page that actually organises sources: the ecosystems, the
by-language browser and DNS all live on SourcesPage, which had become
reachable only through an overflow menu on a page you had to flip to
first. The pill opens that instead, and the catalogue is reached from
there, where it belongs.

1327 tests, analyzer clean.
Aspect ratio still opened a modal. The tap-to-cycle went on the player
BAR's fit button, and the bar is not where anybody reaches this — it is
reached from Settings, whose row opened a SECOND sheet over the first to
choose one of three values, covering the video, for a setting whose whole
interaction is "the next one". The row steps in place now and the sheet it
lives in stays open, so two steps cost two taps. It does not toast: the
row is showing the new value already, right where the finger is.

The download sheet said "Quality unknown" over a single Download button
for an HLS MEDIA playlist — segments rather than #EXT-X-STREAM-INF — which
reads as the app having failed to work something out. There was nothing to
work out: that playlist IS one quality. It says so, but only when it is
also the only source; with other mirrors listed above it, "only one
quality" would be a claim the list contradicts, and unknown is then the
honest word. A test caught me getting that wrong the first time.

The episode-list glyph was the lightest thing on a bar of solid shapes —
three thin rules and a triangle next to a gear and a padlock. Back to the
filled one.

Bottom sheets open in 180ms on an emphasised decelerate and close in 140,
against Material's 250/200. On a player, where a sheet is opened to change
one thing and dismissed immediately — from a bar that is itself on a hide
timer — a quarter second of travel is the difference between a control and
a wait.

And the airing calendar reported every non-AniList failure as "Could not
load the airing schedule", which tells the reader nothing and a bug report
less. SourceFailure already knows the vocabulary of both Dart and Dio, so
being offline now says it is being offline. AniList's own API checks out
healthy, so the generic message was hiding something else entirely.

1327 tests, analyzer clean.
Four faults, found by auditing the app for the failure classes that other
Dart streaming clients have had to fix, and each one confirmed here first.

A corrupt box was silently emptied. Hive's `crashRecovery` defaults to ON
and what it does on a bad checksum is `truncate(recoveryOffset)` — the
file is rewritten up to the last frame it could read and the rest is gone
from disk, with `print('Recovering corrupted box.')` the only trace. For a
torn tail that is right; for damage near the front it is the history, the
resume positions, My List and the downloads index. And one file the
filesystem would not hand over took the whole launch: nine bare openBox
calls in one Future.wait before runApp, so the only way out was clearing
the app's data — which throws away the eight boxes that were fine.
BoxRecovery reads twice on purpose: with recovery OFF first, so "I am
about to overwrite this" becomes an exception, then it copies the file
aside and lets Hive keep what it can.

Home loaded twice at once. Eleven places add HomeLoad and bloc's default
transformer runs them concurrently, so what got emitted was whichever
backend answered LAST — not whichever source the viewer picked last.
Switching source twice quickly landed on the first source's rows. A run
token, in the bloc where all eleven meet.

A catalogue title was declared uncarried after eight seconds. A backend
leg is allowed ten on its own and an extension host forty-five, because
its first search has to download and dex-load an APK — so a title came
back "none of your sources has this" before a single on-device leg could
have finished, then opened first try when searched for by hand. Two
deadlines now: take what there is at eight, hold out to twenty-five rather
than call a title uncarried on nothing having answered. Nobody watches a
spinner for it either — the detail page renders the record first and fills
the source in when it arrives, which it used to wait on.

A source was only ever asked under one of the title's names. AniList knows
three and TMDB two, and a source indexes under whichever its own site
uses: animecube lists "Kaiju Girl Caramelise" and answers a search for
"Otome Kaijuu Caraméliser" with nothing at all. The second name is asked
only when the first found nothing usable, so a title that already resolves
costs exactly what it did before.
Two manga readers on a real device, both reporting as a wall of Kotlin.

One threw `kotlinx.serialization.MissingFieldException: Fields [artists,
authors, relationships, ...] are required for type with serial name
'...hentaihand.MangaDto'`. A Tachiyomi source parses its site's JSON into a
declared shape, so a field the site drops throws exactly this — the
extension is behind its own site and its repo usually already has the build
that reads it. Nothing in the app, the network or the site being down.
`incompatible` is the nearest kind there was and it is the wrong sentence:
that one asks for a newer Sozo. So `outdated` asks for a newer SOURCE.

The other threw `java.lang.UnsupportedOperationException: Please enter a
query in the format of gallery:{username}` — a source with no browse feed
at all, saying so, in words, written for the reader. It was the most useful
thing anything said about that screen and it arrived with a Java class name
bolted to the front, which made an instruction read as a crash. The class
name is dropped when what follows is prose.

That unwrapping happens at the very end and only to the headline. Every
classifier keys on exactly these class names — UnknownHostException,
NullPointerException — so doing it earlier would hide from each rule the
word it exists to match, and a source's own crash would come back
unrecognised. Both directions are covered by tests.
`SMangaImpl.title` and `SAnimeImpl.title` are `lateinit`. An entry whose
source never set one does not read back as an empty string — the getter
throws `UninitializedPropertyAccessException`, out of what looks like a
plain field read. `cardJson` builds every browse row, every search row and
every related row from that field, so a single nameless entry anywhere in a
page threw the page away and the reader saw a source that simply does not
work.

Both hosts already knew. MangaHost guards the title on the details path;
AniyomiHost guards it on the details path AND in a log line two methods
above `cardJson`, which is where the getter was first caught throwing. The
row the reader actually looks at was the one place left.

A row with no name now falls back to the slug in its own url — the name the
site itself uses. It is legible, and it still opens. Dropping the row
instead would shorten a page with no way to tell that it had.
Three things a reader needs that Sozo had no way to express.

**What has been read.** The app knew whether a chapter had been reported to
a tracker this session, and where the reader had got to in the one chapter
it was last in. Neither survives as "I have read this", so a list of four
hundred chapters looked identical whether you had read one of them or three
hundred — the single most useful thing a list can tell you, missing.

ChapterReadStore keeps chapter NUMBERS per source and title. Not indices,
which move the moment a source inserts an extra; not refs, which change when
a source moves domain — either would silently un-read a library. The number
is the one identity a chapter keeps, and it is what a tracker is told, so
the two agree by construction.

Append-only except for an explicit unmark: reading on, going back, closing
and reopening, and the session ledger emptying all leave a mark where it is.
The reader writes it BEFORE the tracker gate, because whether a chapter has
been read is a fact about this device and whether anybody was told is a
different question — tied together, the list could only show progress to
somebody signed in to AniList. Still behind the incognito guard.

A read row dims, and the row you are on does not: "where I am" outranks
"what I have done" on a list somebody just opened to carry on. Selection
gets one button that flips between Mark read and Mark unread — two would
both be live on a mixed selection and neither would say which the selection
needed.

**Opening a chapter where it was published.** The blocker was never the UI.
A Mihon or Aniyomi extension stores `SManga.url` and `SChapter.url` as paths
relative to its own `baseUrl` — `/manga/x/chapter-1`, not a link — so the
app held nothing a browser could be handed, for the whole extension
ecosystem. The hosts are the only place with both halves, so they resolve it
and send `webUrl`; a source that cannot answer omits the field and the
action does not appear. http(s) only, so a source handing back an app scheme
cannot be launched into whatever claims it.

**A security policy.** Reporting a vulnerability had no private channel, so
the only way to report one was to publish it.
A novel chapter could not be downloaded at all. A comic source implements
`getPageList` and answers with image urls; a novel source implements
`getHtmlContent` and answers with one document, and the download path only
ever looked at the first — so a chapter arrived with zero pages and the
transfer failed the whole thing with "the chapter has no pages", on a shelf
the app already has a reader for.

Prose is a second shape of the same kind, not a new kind. The document is
written into the chapter's folder and every picture it points at is fetched
in beside it, with the `src` attributes rewritten to those file names. That
is what makes deleting a chapter delete its pictures: a folder goes as one
thing, where an html file and a pile of images next to it would not. One
rewriter, used in both directions — file names on the way to disk, absolute
paths on the way back out — so the two cannot disagree about what a `src`
is. An image that will not fetch keeps its original url rather than failing
the chapter: it loads when there is signal and shows a gap when there is
not, which beats no chapter at all.

Prose stays on the in-process transfer even on Android. The native
downloader takes a url or a page list and knows nothing about this shape;
teaching it would be a foreground service, a notification and a second HTML
rewriter, for a document and a handful of pictures that finish in about as
long as the notification takes to appear.

And it comes back out. A downloaded novel lived in the app and nowhere else
— no way onto an e-reader, no way to send it, nothing left once the app is
gone. Export now builds an EPUB of every downloaded chapter of that title,
in chapter order, because an EPUB of one chapter is a strange object and
somebody who downloaded thirty and pressed export wants the thirty.

Written by hand: the format is a zip with four small XML files in it and a
dependency for that would be more code than this. Two rules of the container
are easy to get wrong and both are covered by a test that fails without
them — `mimetype` must be the first entry AND stored rather than deflated,
because a reader identifies the file by those bytes at a fixed offset; and
every document must be well-formed XML, which scraped HTML is not. `<br>`,
`<img>` and the rest are closed, and a bare `&` is escaped without touching
a real entity.
Every piece of evidence the source order was built on came from the search:
did it answer, and how fast. So a source that answers in 200ms and then
cannot produce a stream sat ahead of one that takes a second and always
plays — the wrong way round for the thing the viewer is actually asking for.
Searching well and playing are different skills.

The play is recorded at the first frame, not at resolve. A resolved url is
not a playing one: a dead mirror, a 403 on the first segment and a codec the
device cannot decode all resolve perfectly and never play. It goes against
the source that SERVED it, which is `widget.args.provider` without
qualification — switching source mid-episode builds a whole new PlayerArgs
around the new provider rather than swapping a url underneath the old one.

Saturated at three. Left to grow, the tally only ever goes up, and two
sources that had both clearly worked would be ordered by a margin nobody can
see or change — fifty plays sitting above forty-nine forever, even when the
forty-nine answers in a fraction of the time. Past the cap, proven is proven
and the measured answer decides. Three rather than more because the
difference between one play and five is mostly how much of a series somebody
happened to watch.

Health still outranks it: a source that timed out last time has not earned
the front by having played a week ago, and its shortened budget makes it
cheap to ask late anyway. Below both, the order the list arrived in — the one
the user arranged — is untouched, which is what the cap is really protecting.
A page is decoded once, scaled to the column it is drawn in, and then
zoomed — so pinching magnifies a bitmap that was thrown away at screen
width. On a dense page, a double-page spread, or anything with small
lettering, that is the difference between reading it and not, and it goes
wrong at exactly the moment somebody is looking closely.

Decoding at full size instead is not an option. A webtoon strip is tens of
thousands of pixels tall and one of those in ARGB_8888 is hundreds of
megabytes, which is the other half of why the reader downsamples in the
first place.

`BitmapRegionDecoder` is the way out: it holds the file rather than the
image and answers for any rectangle at any sample size. Past 1.35x, the part
of the page that is on screen is re-read at the size it is being shown at
and painted over the top. The base image is never removed — it is what is
there while a tile decodes, what stays when the decoder declines the file,
and what the whole widget falls back to off Android.

The details that decide whether this helps or hurts:

* The sample size rounds towards MORE detail. A tile decoded smaller than
  the space it is drawn in is blurrier than the page it replaces, which
  would make a sharp zoom actively worse than no zoom.
* Decoding waits 120ms for the gesture to settle. On every frame of a pinch
  it would queue dozens of reads for viewports nobody ever looked at.
* The rect is padded by a quarter of a viewport, so a small pan lands inside
  what was already decoded and costs nothing.
* Tiles are PNG. A JPEG's ringing would show as a seam where the tile meets
  the page underneath.
* Handles are closed when the page changes and when the widget goes. The
  decoder holds a file descriptor and a native buffer, so a reader that has
  scrolled two hundred pages would otherwise hold two hundred of each.

Downloaded pages already have a path; network pages get one from the file
cached_network_image wrote on the way in, so nothing is fetched twice.

The decode is a platform call, but the arithmetic around it is not, and that
is the part that is easy to get wrong — so the sample size and the visible
rect are pure functions with exact tests, including that the sample can
never land under the target.
Sozo could read novels and had almost nowhere to read them from. A novel
source here is a Mangayomi source whose repo index declares
`itemType: novel`, and those are a handful next to the manga ones — most
installs carry none. That is the whole reason CatalogueResolver has to widen
the light-novel shelf to the comic readers and caveat every answer it gets
back: there was usually nothing better to ask.

LNReader's index is 279 novel sources. Its plugins are CommonJS bundles
rather than Mangayomi classes, and that difference is the ONLY difference
that matters — they are both JavaScript, they both run in the WebView the
app already hosts, and they both end up in the same registry. So the whole
adaptation is one file and one branch in the loader. Nothing downstream —
search, home, detail, chapters, the reader, offline download, EPUB export,
read state — can tell the two apart, because the shim hands back the same
object shape those were already written against.

What the shim provides is what the plugins ask for: the `@libs/*` modules,
`dayjs`, `qs`, and cheerio. Cheerio is only hard if you port it; this runs
somewhere with a real DOMParser and real querySelectorAll, so it is a
wrapper over a NodeList. Its contract is preserved exactly where it counts —
every call returns a wrapped set rather than a bare node, so a plugin whose
site changed comes back empty instead of taking the page down, and `.map()`
returns a set whose `.get()` is the array because that is what plugins write.

fetchApi goes through the fetch the bridge installs, which is what carries
Sozo's user agent, its cookie jar and any Cloudflare clearance the app has
earned. Reaching the raw one would be blocked where the rest of the app is
not.

Verified against the real corpus, not a fixture: all 279 published plugins
load through the shim and expose every method the app calls, with no unknown
modules left. One of the 279 decrypts its chapters with synchronous AES-GCM,
which WebCrypto cannot do and a hand-written block cipher should not; it
fails with a sentence naming the reason rather than a stub quietly returning
garbage.

The two index shapes are told apart by what their entries carry, never by
hostname, so a mirror, a fork or a local copy of either still works. Plugin
ids are prefixed, because the two ecosystems share one registry and a
collision would serve one source's chapters under another's name.
The chip row could only ever offer one axis, and for the extension
ecosystems the ecosystem is not the end of the question. Somebody with six
CloudStream repositories installed has six separate sets of sources sitting
in one alphabetical run of two hundred rows: "CloudStream" is not an answer,
it is the same two hundred rows. Every host already reported which
repository a source came from — it was being written straight into
`description` and read by nobody, which is why the filter could not exist.

A menu instead of chips, for reasons that get worse the more sources
somebody installs. The row is a second scrollable strip above a list that
already scrolls, so the gesture is ambiguous and the fifth chip is found by
dragging; it costs a strip of screen where a menu costs a line; and it can
only show one axis where a menu has room for two. It also states the current
selection in words rather than as whichever chip happens to be filled in.

Repositories sit under the ecosystem they belong to, and only where there is
more than one of them — a single entry is a label pretending to be a
control, saying nothing the row above it has not. A repo url is shown as the
account it belongs to, because that is how these are actually talked about,
with the boilerplate dropped as whole words wherever it sits:
"cloudstream-extensions-hexated" is a name with noise in it.

The scope survives a tab change and a search, so it routinely names
something the current list no longer contains. It falls back one level at a
time: a repo that has vanished leaves its ecosystem selected, an ecosystem
that has vanished shows everything. Holding a dead filter would show an
empty list and blame the search for it.

The same control is now on the dashboard sheet, which had a text field and
nothing else — and typing only helps when you already know the name you are
looking for. Favourites are filtered by it too: they are a shortcut into the
same list, and CloudStream favourites above an Aniyomi-filtered list reads
as the filter having missed them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant