Skip to content

Mobile: unfreeze sources, rework search and player, AniList calendar, forgot password, TV remote - #12

Merged
professorDeveloper merged 9 commits into
masterfrom
feat/mobile-ux
Aug 19, 2026
Merged

Mobile: unfreeze sources, rework search and player, AniList calendar, forgot password, TV remote#12
professorDeveloper merged 9 commits into
masterfrom
feat/mobile-ux

Conversation

@professorDeveloper

Copy link
Copy Markdown
Owner

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

  • Airing calendar (/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.
    • Days are fetched on demand and kept: a fortnight of global airings is thousands of entries and most of the window is days nobody opens. Per-day staleness (10 min for today, 6 h otherwise), one-day-ahead prefetch, DST-safe date arithmetic, and a midnight/resume re-anchor — an app left backgrounded overnight came back showing yesterday under a strip starting at today.
    • The query pages: AniList caps perPage at 50, so asking for one page silently truncates the evening. It also backs off on 429, which flicking the day strip will earn.
    • addToList reads before it writes. SaveMediaListEntry is 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".
  • Search results carry the AniList mark when the title is already linked. Cross-search passes each hit's own provider; the single-source grid falls back to the active one.
  • The official logo artwork replaces the hand-drawn mark.

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. 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. invalidate() drops that cache, or it would hand back the stale build it exists to replace.

Search

  • One SearchState replaces 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.
  • 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".
  • 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. (Verified against the backend, which does have a separate /contents/genre/:genre.)
  • 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.
  • Results keep the previous grid behind a progress bar rather than blanking on each keystroke; empty and error states say what actually happened. Recent searches persist.
  • Focus stopped painting a saturated red rectangle around the field — louder than anything else on screen. The brand colour carries in a soft glow.

Player

  • Server and quality are separate controls, grouped by host, and switching host keeps your resolution where that host has it, falling back to its highest rather than its first.
  • They were also swapped for some sources — the sheet offered a server called "480p" and a quality called "1-server". The splitter assumed the host came first; it decides by what a part looks like now. Labels with no resolution stay whole, since taking the first part of "SUB · Mp4Upload" makes a language tag into a host. Ten tests cover it.
  • Before a source resolves the server row shows nothing rather than the literal "Default".
  • iOS volume and brightness swipes did nothing. soplay/system_controls was implemented on Android only, so every call threw MissingPluginException into a catch (_) {} — 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-screen MPVolumeView — off-screen rather than hidden, because isHidden stops it responding, and set a tick late, or the first swipe of a session is dropped.

Forgot password

The backend has served /auth/forgot-password and /auth/reset-password all 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 AuthLoaded rather 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_scanner is recorded in ios/Podfile.lock, which never had it.


flutter analyze clean apart from two pre-existing infos. 46 tests pass. Debug APK and flutter build ios both 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; MPVolumeView does nothing on the simulator.

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.
@professorDeveloper
professorDeveloper merged commit 62512d6 into master Aug 19, 2026
@professorDeveloper
professorDeveloper deleted the feat/mobile-ux branch August 19, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant