Work/2026 09 14 - #40
Open
professorDeveloper wants to merge 51 commits into
Open
professorDeveloper wants to merge 51 commits into
professorDeveloper wants to merge 51 commits into
Conversation
## 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All bugs fixed Ready to new version