Skip to content

Integrate MyAnimeList tracking and enhance Live TV experience - #25

Merged
professorDeveloper merged 23 commits into
masterfrom
improve-app-and-fix
Sep 10, 2026
Merged

Integrate MyAnimeList tracking and enhance Live TV experience#25
professorDeveloper merged 23 commits into
masterfrom
improve-app-and-fix

Conversation

@professorDeveloper

Copy link
Copy Markdown
Owner

No description provided.

professorDeveloper and others added 23 commits August 24, 2026 09:51
Live TV already told the player what it was handing over — `type: 'live'`
— and the player never read it. Whether a stream was live was guessed
from its duration alone, and plenty of live HLS carries a DVR window and
reports a perfectly finite one. Those channels were treated as files: a
scrub bar over something unscrubbable, frame-preview extraction against
an endless stream, and the live edge read as the end of the video.

So the declared type decides, and it is known before the first frame
rather than after it, because a channel that is down when you open it
fails during initialize() and that is exactly the path that needs to know.

Reconnection is the other half. A film either plays or is broken, so four
retries a session is generous; a channel drops because a segment was
late or a CDN failed over, and the only right answer is to open it again.
Four in a two-hour evening meant the player gave up for good on something
that was working seconds later. A broadcast now reconnects with a backoff,
on errors a file would call fatal — a 403 on a live edge is usually a
rotated token, and the next playlist fetch has the current one.

Seeking is refused outright. The double-tap gesture reached the same
seek as the scrub bar, and a tap landing outside the DVR window stalls
the stream with no way back but leaving the channel.

Channels also carry the headers their origin insists on, all the way to
the player and into the pinned-card cache — a header-gated channel used
to work once and 403 forever after being favourited.
It was reachable only through Profile, seven rows down a settings list.
That is a fine place for a preference and the wrong place for a thousand
channels: Live TV is content, and it belongs where the content is.

Home grows a rail of channel logos above the catalogue sections. A rail
rather than a link, because a channel is picked in a second and watched
immediately — tapping a logo plays it, and the header still opens the
full page for folders and search. Logo-forward and square, since a
channel is recognised by its mark long before its name is read.

It loads itself and renders nothing until it has channels, so a backend
with no line-up — or no network — leaves Home exactly as it was rather
than showing an empty shelf.

Live TV also remains available as a bottom-bar tab; it has been in the
registry all along, just not on by default.
AppColors.primary is a getter — the accent is a user setting — so it
cannot appear in a constant expression, which is exactly what
app_colors.dart says at the top. The LIVE dot's decoration claimed const
and failed to compile.
The screen was a search box, a folder grid and a paged wall of logos. It
told you a thousand channels exist and nothing about any of them, and the
guide the backend had started returning was not read at all.

Channels now carry their listing. A card shows what is on now, how far
through it is, and what follows; a long press opens the channel with its
schedule for the day. About 38% of the line-up has no guide, so every one
of those paths is written to look deliberate without one rather than to
show a gap where a title should be.

Around that: categories and countries are one index rather than two
competing navigations, pinned and recent channels lead, and the states
that were bare — first load, loading more, no results, offline — are all
drawn. Country names are in English, like the channel names beside them;
a strip reading "O'zbekiston" over a grid of "Pluto TV Comedy" was two
languages doing one job.

The clock is the part worth being careful about. Times arrive as UTC and
are converted on the way in, so an hour rendered is the viewer's own;
progress is clamped at both ends so a stale payload reads as finished
rather than as 140%; and a bar is only drawn for a slot between five
minutes and six hours, because a feed padding a thin guide with a
nine-hour block would otherwise show a bar that has not moved since
breakfast.
kSozoUserAgent existed for a good reason — Cloudflare binds cf_clearance to the
exact agent that solved the challenge, so one string everywhere is right. But
the string it settled on was invented: `Chrome/125` on a device whose WebView is
some other version entirely.

A managed challenge does not only read the header. It compares what the header
claims against what the engine behind it is — client hints, userAgentData,
platform, version. Measured against animepahe.pw, which sits behind one: driving
a real browser through the challenge, the agent that matched its true platform
cleared in about four seconds, while every agent that misdescribed the platform
sat at "Just a moment..." until the watchdog gave up. Same browser, same flags,
same window size; only the header differed.

So the value is read from the device's own WebView at startup and reused
everywhere, with the old constant kept only as a fallback. The `; wv` token is
dropped — it is the one edit that does not make the string less true.

channel_sheet.dart is unrelated: showAdaptiveModal was used without its import,
which failed analysis and would have failed the build.

Co-Authored-By: Azamov X <108933534+professorDeveloper@users.noreply.github.com>
The sniffer keeps whatever url the page requested. For a player that asks for
one rendition and never for the master, that pins playback to a ladder of one —
the qualities exist, the page simply never asks for them, and the master sits
under a name derivable from the variant's.

So the extractor directive can now carry a `rewrite`: a regex the server sends
and this applies to the sniffed url. No provider name reaches the app; the rule
travels with the source, the same way blockHosts and urlPatterns already do.

Verified before it is trusted. A derived url is a guess about someone else's
naming, and a guess that 404s must not cost a user their playback, so the
candidate is fetched and kept only if it answers as a playlist. One request,
and only when a rule was sent at all.

Co-Authored-By: Azamov X <108933534+professorDeveloper@users.noreply.github.com>
Two conflicts, both in files this branch already touched.

`dart_fetch.dart` — master still declares the solve agent `const`. It cannot be
one any more: the app now reads its User-Agent from the device's own WebView, so
`kSozoUserAgent` is a getter and `final` is the only thing that compiles.

`channel_sheet.dart` — master's copy calls showAdaptiveModal without importing
it, which fails analysis. This branch added the import; the merge keeps it.

Co-Authored-By: Azamov X <108933534+professorDeveloper@users.noreply.github.com>
Three conflicts, all resolved in master's favour, and none of them arbitrarily.

`user_agent.dart` and `dart_fetch.dart`: this branch strips the `; wv` token
from the device agent and solves Cloudflare challenges inline. Master reworked
both a day later — "Make a Cloudflare solve mean a clearance was actually
earned" and "Solve Cloudflare challenges outside the JS fetch handler" — and its
comment names the older approach and rejects it: the engine really is an Android
WebView, so a UA claiming plain Chrome is the mismatch a managed challenge looks
for. Keeping this branch's side would have reintroduced what master had already
moved past.

`player_page.media.dart`: both hunks are pure master additions
(`_expandMasterPlaylist` and its call site). The conflict was adjacency, not
disagreement — this branch's side of both hunks is empty.

Resolved hunk by hunk rather than by taking whole files, so everything git
merged cleanly on this branch is kept.
…in screen

The language lived in two places that never spoke to each other.
`context.setLocale()` persists through easy_localization's own storage and is
what the interface reads; `HiveService.saveLanguage()` is what the subtitle
translator falls back to and what the backend is told for push copy. Only the
settings screen wrote the second one, and nothing wrote it at startup — so a
German phone showed a German interface while Hive still said `en`, and that
user's subtitles and notifications quietly stayed English.

Both writes now go through `AppLanguage.set`, and `syncFromDevice` repairs
accounts already in the split state: on a first launch the resolved locale
comes from the device, and Hive's own default is the string `'en'`, which is a
real answer rather than an absent one, so nothing downstream could tell
"English" from "never asked".

`useFallbackTranslations` is enabled. Without it a key missing from one locale
renders as the key itself — the user reads `profile.language_desc` where a
sentence belongs. CI gates on the key check, so it should never fire; it is
here because the day it does, English is a far better answer.

The picker is a chip on the sign-in screen rather than a screen of its own.
The app already resolves the device locale by itself, so a dedicated first-run
language step would charge every user a step to serve the minority whose phone
is set to something they do not want to read. The chip costs nobody a step,
and the slides re-render in the chosen language immediately — which a picker
placed after the introduction cannot do, because by then the introduction has
been read in the wrong one.

Registering the FCM token now carries the language up with it, keyed on the
language as well as the token so switching language re-registers instead of
being swallowed as a duplicate.
Eleven languages now, up from four. All seven new ones are Latin-script: the
app bundles no font, so the system one covers them, and none needs the mirrored
layout Arabic does.

`pt`, not `pt-BR`, though the copy is written in Brazilian Portuguese. A region
tag would have been the only two-part code in the set — a special case in the
file names, in the check script, and in `kSubtitleTranslateLanguages`, which
lists plain `pt` and would have silently dropped a `pt-BR` reader back to the
default subtitle target.

The copy is machine-generated and has NOT been reviewed by a native speaker.
German and Dutch are the two markets this was asked for; treat reports about
their wording as expected rather than surprising.

`localization_test.dart` reads the locale list off disk instead of naming four
files. A hard-coded list silently stops covering the locale nobody updated it
for, which is the exact failure the file exists to catch.

`check_translations.dart` now blocks only on `en`, `uz` and `ru` — the three
the team writes. Blocking a PR on the other eight would not produce eight
translations; it would produce eight copies of the English string pasted in to
get the build green, which is the same debt with the tracking removed. They are
reported instead, and the fallback added alongside means a gap renders as
English rather than as raw dot-notation.

`check_expansion.dart` ranks short strings by how much longer they got.
Overflow is not random — it happens where the most-expanded string meets the
tightest box — so the ranking names the screens worth opening in German instead
of auditing all sixty. Five sites it found are fixed here: three retry buttons
pinned to 156px that "Tentar de novo" does not fit, and two fixed-width label
columns in the player and the source tester. The player's info labels were
shortened in four locales rather than ellipsised, because "Almacenado por…"
hides the very word that says which row you are reading.
Reported as the app freezing once the installed source list passes a couple of
thousand. Two separate causes, and they compound.

**The screen stopped responding.** `_remerge()` ran on every arriving leg, and
`mergeSearchResults` rebuilds from scratch — it regroups every item of every leg
collected so far and re-scores each one against the query, then sorts. Per
arrival that is quadratic in the number of sources: at 2000 legs, tens of
millions of item comparisons over a run, all on the UI isolate, plus 2000 full
rebuilds of the results list. The engine's network work was already off-thread;
this was not.

Arriving legs are now coalesced into one merge per 250ms window, with an
immediate flush when the run ends so the last legs are never stranded in an
unflushed window. The test that guards it fails at 1000 notifications without
the change and passes under 5 with it.

**The run took over an hour.** Nothing capped the set. The pool is bounded at 5
with a 10s per-provider budget, so a run costs roughly `legs / concurrency *
timeout` in the worst case — about 67 minutes for 2000 sources. A search nobody
waits for is a search that did not happen.

`planLegs` caps an all-source run at 60 legs, taken after the existing
health ordering, so the ones kept are the ones most likely to answer. It is
applied where the set is built rather than inside `search`, so the counters the
UI shows — pending, answered, timed out — still describe the run that is really
happening. A scope the user picked by hand is never trimmed: then the count is
theirs. The summary line says which 60 of how many, because a number that
quietly disagrees with the source list is worse than a long search.

`SearchFanOut` is a narrow interface over the two methods a search surface
actually uses. The engine reaches WebViews, a Dio client and the Mangayomi
bridge; standing all three up to assert that legs are batched would have tested
the fakes rather than the batching.
Reported as films downloading blurry with no way to pick a quality — on vidapi,
which is worth naming because it explains why the setting could not be found.

vidapi's `stream_urls` are servers, not qualities: same picture, different
mirror, as its own comment says. So there is no 1080p entry anywhere in the
provider's response to choose — the renditions live inside the HLS master
playlist, one level down.

The player already read them: it parses the master and builds its quality menu
from it. The downloader parsed the same file and took whichever variant was
listed first. Packagers commonly order a master lowest-bitrate-first, because a
client is meant to start conservatively and adapt upward — so "first" was the
480p rendition of the stream the player was showing at 1080p. The download
completed, sat in the list, and looked wrong, with nothing on screen to suggest
a quality had been picked at all.

The ranking now lives in `parseHlsVariants` and both callers use it. Keeping two
copies is what let them disagree, and the subtle half is not obvious enough to
survive being written twice: the packager's own name for a variant beats
RESOLUTION, because a 2.40:1 film encoded at 1080p carries RESOLUTION=1920x800
and ranking on pixel height alone puts it below a true 1080p and labels it 800p.

Nobody downloads a film to keep the smallest copy of it, so the best rendition
is the answer rather than a new setting. The parser also splits on CRLF, which
the downloader's did not — a master served with Windows line endings left a
stray carriage return on every url it built.
Reported as the provider picker being buried, and it was: from Profile it took
Sources, then Active source, then the full providers page. Three screens for
the thing this app asks people to do most often, and the last of them is really
for installing extension repos rather than picking a source.

The home screen already had the answer — a quick-switch sheet with the modes,
the favourites, a filter once the list is long enough to need one, and the home
screen still visible behind it. Its own comment records why it exists: the
report before this one was "I have to go to settings every time I want to change
source". It was just nailed to the top bar.

So it moves out of `home_top_bar.dart` into its own file and both entry points
open the same sheet. Building a second picker for Profile was the alternative,
and two controls for one decision drift apart — this one already carries enough
judgement (offline sources dropped, mode narrowing, the switch animation
covering the reload) that a rebuild would have quietly lost some of it.

Profile now shows two rows where it showed one, because they are two jobs:
`Provider`, which names the current source and opens the switcher, and
`Extension sources`, which goes to the management screen. The rule is the same
everywhere now — a row that shows the current source is the row that changes it,
including the one on the sources screen, which used to push the providers page.

`home_top_bar.dart` drops from 1152 lines to 676. What is left is the top bar:
the other buttons, the indicators, the shortcuts.

Also localised `Mangayomi Sources`, a title that was still a Dart string
literal — the extraction pass looked at layouts and `.text =` assignments, and
this one was neither.
Three related defects, all from the same wrong assumption: that a provider's
source order is already ranked.

SourceLadder ranked by remembered quality, then isDefault, then backend order,
documented as "already ranked". It is not — asilmedia and animefenix mark
nothing default and list 360p ahead of 720p, so the ladder opened on the worst
rendition. Resolution is now rule 3, parsed from the label; a label with no
resolution in it scores 0 and keeps the backend order, so vidapi's
"Server 1/2/3" is unaffected.

detail_page's _pickMovieUrl had its own hand-rolled version of the same walk,
with the same defect, feeding the downloader. It calls the ladder now.

_expandMasterPlaylist could only be reached through _applyRewrite — a provider
with a rewrite rule that verifies, and a source list of exactly one. vidapi has
no such rule and returns three masters, so the sheet listed servers, the
renditions inside them were never surfaced, and only the connection speed chose
between 480p and 1080p. Playback now reads the current server's master once and
inserts its heights after it, leaving that entry untouched as the adaptive
choice; other servers expand when switched to.
A scan for string literals reaching a widget's visible slots found 60-odd that
never went through .tr(): the whole app-updater flow, the CloudStream, Aniyomi
and Mangayomi source pages, the repo-file import dialog, the log viewer, and a
"FAQAT KO'RISH" badge sitting hardcoded in Uzbek on the providers page — which
read as Uzbek to a German user.

82 keys across all 11 locales. Numeric interpolations, brand names and the
font-preview pangram are left alone deliberately.
cross_search_controller_test imports it and it only resolved as a transitive
dependency of flutter_test.
@professorDeveloper
professorDeveloper merged commit a824bd7 into master Sep 10, 2026
4 checks passed
@professorDeveloper
professorDeveloper deleted the improve-app-and-fix branch September 10, 2026 09:21
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