Mobile: unfreeze sources, rework search and player, AniList calendar, forgot password, TV remote - #12
Merged
Merged
Conversation
Replaces my reconstruction with the file from Wikimedia Commons. It carries its own colours — dark tile, blue mark, white A — so it is rendered untinted; the colour filter that suited a single-path mark would have flattened it. Corners are rounded at the call site rather than by editing the artwork.
Upcoming is deliberately narrow — only shows you already follow — so there was nowhere in the app to see what a new season brings. The calendar is the wide view: seven days from today, everything airing, with a filter back down to your own list. It works signed out, since the schedule is public; connecting only adds the marks and the filter. Days are fetched on demand and kept rather than pulling the week up front: a week of global airings runs to several hundred entries and six of those days are ones nobody opens. The query pages, because AniList caps perPage at 50 and asking for one page silently truncates the evening. Search results now carry the AniList mark when the title is already linked. Linking is what makes tracking work — a source title rarely matches AniList exactly — but the link was invisible outside the AniList screens, so there was no way to tell from a result whether watching it would count. Cross-search passes each hit's own provider; the single-source grid falls back to the active one.
The JS runtime kept a single headless webview and a single globalThis.Provider, so every extension call took a global lock and a different provider meant re-evaluating that extension's whole script. A cross-search over six sources therefore ran six network waits back to back with six script parses between them — the pool's concurrency was decorative, and the app looked frozen. Extensions now register once into a per-name registry and calls resolve their provider by name, so nothing mutable is shared and the lock is only needed while registering. The calls overlap, which is the point. Provider stays lexically bound inside each extension's IIFE, so an extension referring to it by name still reaches its own object. The Mangayomi runtime has the same shape but a second reason for its lock — preferences are a page-global seeded per call — so only the compile cache lands there: switching back to a source seen this session is a pointer assignment rather than another new Function(code). invalidate() drops that cache, or it would hand back the stale build it exists to replace.
…player Search. Six mutually-exclusive states become one, and every awaiting handler carries a run token, so the type-clear-retype, provider-switch and genre-apply races stop painting a stale answer. Genres are fetched once per provider instead of on every clear, so a genres failure can no longer show the idle screen as 'no internet'. Results keep the previous grid behind a progress bar rather than blanking on each keystroke, and the empty and error states say what actually happened. One result card serves both grids. Genre no longer composes with a text query. The server's /contents/search reads only q, page and provider — a genre passed alongside a query was dropped, so the user got an unfiltered search back under a lit filter chip. Picking a genre browses that genre; typing clears it. The parameter is gone from the API rather than left as a promise nothing keeps. Detail. Seven circular buttons over the hero art become the primary pill, the list toggle, and an overflow sheet that shows each action's state — a menu cannot. The long-press-to-private gesture and the showcase survive. Profile. AniList moves out from under Watch History to near the top. The providers picker becomes its own routed page, which takes about a thousand lines out of a 3400-line file. Player. Server and quality are separate controls, grouped by host, and switching host keeps the resolution where that host has it — falling back to its highest, not its first, which is arbitrary. Before a source resolves the server row shows nothing rather than the literal 'Default'. Calendar. A fortnight rather than a week, DST-safe day arithmetic, per-day staleness, prefetch, a now marker, and same-minute airings collapsed into one row. AniList answers 429 under a fast day-strip, so the client backs off. addToList reads before it writes. SaveMediaListEntry is an upsert, so sending progress 0 and PLANNING would have reset a title the viewer was twelve episodes into — reachable whenever the library merely failed to load, since that also reads as 'not on your list'. Cross-search no longer pins itself in a permanent spinner on a one-character query, and Load more refuses a second tap instead of paying for the same page twice. My Lists tabs fill the bar again. The floating nav is frosted like the classic one. flutter analyze clean but for two pre-existing infos; debug APK builds.
The backend has served /auth/forgot-password and /auth/reset-password since before this app, emailing the code through Resend, and nothing on the phone reached them — so a forgotten password meant a new account. One page, two steps: ask for the address, then take the emailed code and the new password together. Splitting the second step across two screens would mean holding a verified code while navigating, and the code is only useful alongside the password anyway. The reset issues a session, so success lands in AuthLoaded and the router takes the user into the app rather than back to a login form they have just proved they can pass. Two details worth keeping: The server answers a forgot-password identically whether or not the address exists, and this screen does the same. Reporting "no such account" here is how an app leaks which emails are registered. The repeated-password field is checked before submitting rather than only server-side: a typo would otherwise burn the one-time code and cost another email. Resending goes through forgot-password again — the server treats a second call as a resend, and there is no separate endpoint for this flow to drift from. flutter analyze clean but for two pre-existing infos; debug APK builds.
The phone half. Pairing already existed and the server relays commands; this is the part a person touches. Two things earn the screen: a keyboard, because entering text with a d-pad is the worst thing about any TV app, and transport controls, because the physical remote is always on the other sofa. Typing here runs the search on the TV. Detail gains "Play on TV". The TITLE is what travels, not the url — the two apps do not share a source registry, so this app's contentUrl means nothing to a TV without that provider, and the TV resolves the title against its own sources. The device list is fetched on tap rather than when the sheet opens: most people never press it, and the ones who do can wait for one request instead of everyone paying for it on every open. One reachable TV is the normal case and is used without asking; several get a picker. State is polled every two seconds rather than pushed. The TV reports upward and the phone reads; a socket per remote would cost more than this does, and the remote only renders seconds. A 409 from the server means nothing is listening, which is the ordinary state of a TV that is switched off. It has its own exception type so the UI says "TV is offline" instead of showing a failure. flutter analyze clean but for two pre-existing infos; debug APK builds.
…cking Three things were wrong with the first pass. The polling timer fired every two seconds into an async body, which does not wait for the previous run. On a slow network the requests stack until the page stops responding — the exact failure the feature least survives. Polls now hold an in-flight guard, and the interval follows what the TV is doing: two seconds while something plays, five while it idles, fifteen while it is off. Polling also stops when the app leaves the foreground, since nobody is reading a remote from the app switcher. The device picker was hidden when there was only one TV, which is when "which one am I driving" is least obvious, not most. It is always shown, each entry with a dot for whether that TV is reachable, and a banner above it says in one line which TV this is, whether it is connected, and what it is playing — with a progress bar when there is something to show. Controls used to accept a tap and fail. They are dimmed and inert while the TV is not listening, so the answer arrives before the press rather than after it. The polling rules moved out of the widget into a controller: a rebuilding widget is a bad place to keep a timer, and these are the rules that were wrong.
…earch focus
Three separate reports.
iOS gestures did nothing. The soplay/system_controls channel was implemented on
Android only, so every call threw MissingPluginException straight into a
catch (_) {} — the swipe indicator moved and the volume did not, silently,
which is worse than a gesture that visibly fails. There is an implementation
now, registered alongside the other iOS channels and added to the Xcode target,
without which it would have compiled into nothing.
Brightness there is a plain public API. Volume is not: iOS has no supported way
for an app to set system volume, so it drives the slider inside an off-screen
MPVolumeView — the long-standing workaround. Off-screen rather than hidden,
because isHidden stops the slider responding at all, and the value is set a
tick late, or the first swipe of a session is dropped. If Apple closes it, the
call degrades to reporting the current volume rather than failing.
Server and quality were swapped for some sources: the settings sheet offered a
server called "480p" and a quality called "1-server". The splitter assumed the
host came first, so any provider writing "480p · 1-server" landed backwards. It
now decides by what a part looks like, not where it sits. Labels with no
resolution anywhere stay whole — taking the first part of "SUB · Mp4Upload"
would make a language tag into a host and the host into a quality.
Ten tests cover that splitter now, including both orders and the empty case.
The TV has the same feature but reads the host from its own field rather than
parsing a label, so it never had this bug.
Search focus painted a saturated red rectangle around the field, louder than
anything else on screen. The brand colour carries in a soft glow instead.
It was in pubspec but never in ios/Podfile.lock, so the iOS build resolved it fresh every time and the lockfile did not describe the app that ships.
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.
Everything outstanding on mobile, on one branch. Supersedes #11 — its two commits are the first two here, so close that one rather than merging both.
AniList
/anilist/calendar). Upcoming is deliberately narrow — only shows you already follow — so there was nowhere to see what a new season brings. A fortnight from yesterday, everything airing, with a filter back down to your own list. Works signed out; connecting only adds the marks and the filter.perPageat 50, so asking for one page silently truncates the evening. It also backs off on 429, which flicking the day strip will earn.addToListreads before it writes.SaveMediaListEntryis an upsert, so sending progress 0 and PLANNING would have reset a title you were twelve episodes into — reachable whenever the library merely failed to load, since that also reads as "not on your list".Sources stopped freezing
The JS runtime kept one headless webview and one
globalThis.Provider, so every extension call took a global lock, and a different provider meant re-evaluating that extension's whole script. A cross-search over six sources ran six network waits back to back with six script parses between them — the pool's concurrency was decorative, and the app looked frozen.Extensions now register once into a per-name registry and calls resolve by name, so nothing mutable is shared and the lock is only needed while registering. The calls overlap, which is the point.
Providerstays lexically bound inside each extension's IIFE, so an extension referring to it by name still reaches its own object.The Mangayomi runtime has the same shape but a second reason for its lock — preferences are a page-global seeded per call — so only the compile cache lands there.
invalidate()drops that cache, or it would hand back the stale build it exists to replace.Search
SearchStatereplaces six mutually-exclusive ones, and every awaiting handler carries a run token: the type-clear-retype, provider-switch and genre-apply races stop painting stale answers./contents/searchreads onlyq,pageandprovider— a genre passed alongside a query was dropped, so the user got an unfiltered search back under a lit filter chip. Picking a genre browses that genre; typing clears it. The parameter is gone from the API rather than left as a promise nothing keeps. (Verified against the backend, which does have a separate/contents/genre/:genre.)Player
soplay/system_controlswas implemented on Android only, so every call threwMissingPluginExceptioninto acatch (_) {}— the indicator moved and the volume did not, silently. There is an iOS implementation now, registered with the other channels and added to the Xcode target, without which it would have compiled into nothing. Brightness is a public API; volume is not, so it drives the slider inside an off-screenMPVolumeView— off-screen rather than hidden, becauseisHiddenstops it responding, and set a tick late, or the first swipe of a session is dropped.Forgot password
The backend has served
/auth/forgot-passwordand/auth/reset-passwordall along, emailing the code through Resend, and nothing on the phone reached them — a forgotten password meant a new account.One page, two steps. The reset issues a session, so success lands in
AuthLoadedrather than back at a login form the user has just proved they can pass. The screen answers identically whether or not the address exists, because the server does — saying "no such account" is how an app leaks which emails are registered. The repeated-password field is checked before submitting: a typo would otherwise burn the one-time code.TV remote
Needs sozo-backend#13 and sozo-tv#18.
A remote page — a keyboard, because entering text with a d-pad is the worst thing about any TV app, and transport controls. Detail gains "Play on TV"; the title travels, not the url, since the two apps share no source registry.
State is polled, not pushed. The first pass fired every two seconds into an async body, which does not wait for the previous run — on a slow network the requests stack until the page stops responding. There is an in-flight guard now, the interval follows what the TV is doing (2s playing / 5s idle / 15s off), and polling stops when the app leaves the foreground.
A banner names the TV and says whether it is listening; every device shows a reachability dot; controls are dimmed and inert while the TV is off, so the answer arrives before the press rather than after it.
Elsewhere
Detail's seven circular buttons become the primary pill, the list toggle, and an overflow sheet that shows each action's state. AniList moves out from under Watch History to near the top of the profile, and the providers picker becomes its own routed page — about a thousand lines out of a 3400-line file. The floating nav is frosted like the classic one.
mobile_scanneris recorded inios/Podfile.lock, which never had it.flutter analyzeclean apart from two pre-existing infos. 46 tests pass. Debug APK andflutter build iosboth succeed.Not verified: the TV remote end to end — that needs the backend and TV PRs deployed together. iOS volume specifically needs a real device;
MPVolumeViewdoes nothing on the simulator.