Skip to content

iOS 26 player: native UIKit Liquid Glass controls - #71

Merged
professorDeveloper merged 26 commits into
release/3.2.1from
fix/player-glass-hold
Nov 9, 2026
Merged

professorDeveloper merged 26 commits into
release/3.2.1from
fix/player-glass-hold

Conversation

@professorDeveloper

@professorDeveloper professorDeveloper commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

What this PR does (iOS 26+ only)

On iOS 26+, the player's controls are the phone's own UIKit Liquid Glass buttons, not Flutter widgets on top of a glass imitation. The rule is Swift for the controls, Flutter for the layout.

  • ios/Runner/NativePlayerBar.swift (soplay/native_player_bar): one hosted view per group of controls, about 5 on screen instead of about 15.
    • Groups: each group is a UIGlassContainerEffect (12 pt apart). Inside it, either clear glass capsules holding plain buttons, or separate .glass() / .prominentGlass() circles.
    • Glyphs: SF Symbols, 17 pt medium on bar buttons, 22 pt for skip and 28 pt for play.
    • Tint: none by default. Only the unlock pill gets one (black 0.6) for contrast.
    • Show and hide: no alpha on the glass. The capsules dematerialise by losing their effect inside the animation, and a hidden bar is isHidden.
    • Menus: long-pressing speed, fit or sleep opens a native UIMenu with the current choice checked. A tap does what it did before. Every other sheet stays in Flutter.
  • lib/core/native/ (Dart side):
    • Dart lays out every slot, so RTL is handled in one place, and Swift places it.
    • Taps hit only the slots; the gaps go to the player's tap, double-tap and swipe gestures.
    • VoiceOver labels; the skip labels carry the real step.
    • A Flutter fallback for each bar.
  • Seek bar and time labels stay Flutter. A UISlider would lose the thumbnails, AniSkip markers, the reaction heatmap and the buffered track.
  • Earlier in this PR:
    • the clear-glass plate under controls (MediaGlass on NativeGlass);
    • the shader glass held to its tint while the controls fade;
    • Podfile.lock with the ML Kit pods.

Unchanged: iOS 15–25 (shader glass), Reduce Transparency (plain plate), Android, TV and desktop.

Checks

  • flutter analyze: no issues.
  • flutter test test/core test/features/detail: passes.
  • flutter build ios --release --no-codesign: builds.
  • Two adversarial reviews found five issues, all fixed:
    • the busy ring froze;
    • the unlock pill lost contrast;
    • the fit glyph never changed;
    • VoiceOver read some labels twice;
    • the skip label was wrong.
  • Installed and launched on an iPhone 17 Pro Max (iOS 27.0.1) with a local development signature. That signature is not committed.

Not verified

  • Sizes are the system's toolbar-button sizes, not measured against Apple's own player.
  • The look on the phone still needs the owner's screenshots and approval.

On an iPhone the controls stand on refracting glass. A FadeTransition over
it composites the glass and the picture under it on every frame of the
220 ms fade, each time the controls show or hide. GlassHoldWhile puts the
glass down to its tint for the fade, and the group cross-fades the
refraction back once it has settled. Android, TV and desktop draw no glass
over video and are unchanged.
The player's controls stood on a Flutter shader that imitates iOS 26's
clear glass. On an iPhone whose OS has UIGlassEffect with the clear style,
each control now stands on the real material, hosted through the existing
NativeGlass bridge. It refracts the video and the page alike, and follows
Reduce Transparency and Increase Contrast as Apple maintains them. A layer
that goes native draws no shader group, because hosted glass inside a
refracting group would ruin it. Older iPhones keep the shader, not the
frosted system blur. Android, TV and desktop are unchanged.

Podfile.lock now includes the ML Kit pods added with page translation.
@professorDeveloper professorDeveloper changed the title Player: hold the controls' glass to its tint while they fade iOS player: real Liquid Glass under the controls on iOS 26+, glass held during fades Nov 7, 2026
One hosted UIKit view per group of controls (soplay/native_player_bar):
back, the top bar's actions, the cluster round play, the bottom row and
the social rail, each real UIButtons in a UIGlassContainerEffect, as clear
glass capsules or glass circles with play on prominent glass. Dart lays
every slot out (RTL, label widths, text scale) and UIKit places them;
hits land only on a slot, so the gaps stay the page's. The bars fade in
UIKit and leave the frame once faded; only what Flutter draws keeps the
Flutter fade. Speed, fit and sleep open a native menu on a hold, with
VoiceOver's long press still reaching the Flutter sheet.

Gated on a new glassControls capability, Reduce Transparency off, and an
iPhone; iOS below 26, Android, TV, desktop and the small players keep the
Flutter controls unchanged.
- Busy ring keeps spinning after the app or the bar leaves the window
- Unlock pill keeps its deeper 0.6 dimming on the native glass
- Fit button shows the current mode's glyph
- VoiceOver no longer reads a title that repeats the label
- Seek buttons say the real step (player.rewind_by / forward_by)
…glass

Glyphs are 17 pt medium on bar buttons (22 for skip, 28 for play, 13 in the
unlock pill), as the system's glass toolbar buttons are, instead of 20-32
semibold. The clear glass carries no dark wash unless a caller needs the
contrast, which only the unlock pill does. Separate circles stand 12 apart.
Show and hide no longer animate alpha on the hosted root: a visual effect
view, or anything above it, under alpha 1 draws its effect wrongly. The
capsules dematerialise by losing their effect inside the animation, and a
hidden bar is isHidden.
@professorDeveloper professorDeveloper changed the title iOS player: real Liquid Glass under the controls on iOS 26+, glass held during fades iOS 26 player: native UIKit Liquid Glass controls Nov 8, 2026
- Symbols checked against the phone's own list: Watch Party wears
  person.2 (not SharePlay), unlock lock.open.fill, rotate the
  rectangle.*.rotate pair, fit AVKit's zoom arrows and the stretch
  arrows; filled faces for party and cast. A name UIKit lacks falls
  back to its stem instead of drawing nothing.
- Speed, fit, sleep, quality, server, sub/dub and subtitles open the
  phone's own menu on a tap: the current choice checked, a line under a
  row where the sheet had one, a red row for a file that will not play.
  The subtitle menu ends on "More options", the full sheet. VoiceOver's
  tap still opens the Flutter sheet.
- The player's sheets on that path: a grabber, 38pt continuous corners,
  a frosted material, a centred title with a close button, inset grouped
  rows with checks and chevrons, Cupertino glyphs. Everywhere else they
  are the sheets they were.
Settings > Player > Controls style, iPhone and iPad only:
- Native (iOS 26), the default: the phone's own Liquid Glass buttons,
  pull-down menus and iOS sheets.
- System glass: Flutter's buttons on the phone's own clear glass, the
  Flutter taps and sheets.
- Classic glass: Flutter's buttons on the shader glass, the Flutter
  sheets, as in 3.2.1.

Below iOS 26 the first two are disabled and the player keeps the classic
glass; Reduce Transparency still overrides all three. Stored in Hive as
player_controls_style and read when the player opens.
…once

A viewer who never chose a style gets the 3.2.1 classic glass. On iOS 26
with Liquid Glass available, the player asks once, after playback starts
and the controls show, whether to try the native controls. Try it stores
native and swaps the controls in place; Not now or any dismissal stores
classic. The offer is marked shown before it appears.
Every tab change ran setState on the whole detail page (header, action
row, both bars) and the AnimatedSwitcher threw the old pane away, so
Relations and Comments refetched and Similar rebuilt its grid on every
swipe. DetailTabPanes now listens to the TabController itself, builds a
pane on first visit and keeps it offstage afterwards, with the same
160 ms cross-fade.
KissKH's .srt.txt1 tracks are plain SRT with every cue text line
AES-128-CBC encrypted and base64 encoded. The extractor (v7) and
/contents/media tag them with a cipher object; the app ignored it and
showed the ciphertext.

The subtitle loader now decrypts such a track before parsing it, per
docs/SUBTITLE_CIPHER.md: cue numbers, timing and blank lines pass
through, a line that will not open is kept, and an unknown cipher or a
key that opens fewer than half the lines fails the track, as on TV.
Downloads keep the decrypted file, since the sidecar manifest has no
key. Ciphered tracks are not handed to Cast or an external player,
which would only show the ciphertext.

AES comes from package:cryptography (pure Dart), already in the lock
file as a transitive dependency.
On Android, callAsyncJavaScript results arrive as JSON text and decode
to Map<String, dynamic> all the way down. On iOS and macOS WebKit's
objects cross the platform channel, so every nested object is a
Map<Object?, Object?>, and _coerceMap only cast the top level.

For KissKH, a client-mode provider, that meant on iPhone:
- home and view-all failed with "type '_Map<Object?, Object?>' is not
  a subtype of type 'Map<String, dynamic>'";
- a search result opened to the same error on the detail page, and
  the episode list too;
- resolveMedia's whereType<Map<String, dynamic>> silently dropped every
  video source and subtitle.

The answer is now converted to plain JSON types throughout. The test
replays live KissKH v7 payloads through StandardMessageCodec, the same
path the iOS channel takes.
The extractor call now returns JSON.stringify of the provider's answer,
so iOS and macOS decode the same JSON text Android already gets. Before
this, WebKit's conversion sent every object as a Map<Object?, Object?>
and every number as a double. jsObjectFrom converted the maps, but a
view-all page still failed on iPhone with "type 'double' is not a
subtype of type 'int'" because its page number came back as 1.0.

jsObjectFrom still converts channel maps. It now also turns whole
doubles back into ints. New helpers jsonMap and jsonMaps do the same
deep conversion for maps read from a channel or a Hive box. jsonInt and
jsonText read numbers and text that a source sends in either form.

ExtractorRunner no longer hard-casts its decoded answer.
The server's Zangetsu wrapper passes a script's cast, screenshots and
extra through unchanged. One 18+ source sends its screenshots as bare
image URLs. DetailModel cast each entry as Map<String, dynamic>, so its
detail page failed with "type 'String' is not a subtype of type
'Map<String, dynamic>' in type cast" on iOS and Android.

What changed:
- A screenshot may now be a URL or a {full, thumb} map.
- A cast entry may be a map, with its image under image, avatar or
  photo, or a bare name.
- A genre may be a {name} map.
- Year, likes and counts are read from numbers or numeric text.

Home, view-all, search, episodes and media now convert every nested map
through jsonMaps instead of casting it. A map from a channel or a Hive
box no longer throws, and media no longer drops sources that
whereType<Map<String, dynamic>> skipped. ViewAllPagingModel no longer
assigns page, totalPages and provider unchecked. HistoryItem reads its
numbers as num, so a double from a channel cannot fail the
continue-watching row.

The fixture holds an 18+ device source's live answers, with titles,
images and URLs replaced by placeholders, plus the server passthrough
shape. Each payload goes through StandardMessageCodec with doubles, as
WebKit delivers it, and also as JSON text.
- Closing the PiP window pauses and clears PiP state (onPictureInPictureModeChanged
  forwarded over soplay/pip); PiP is marked before entry and rolled back on failure.
- Pause only on paused/hidden on mobile, not inactive.
- Audio focus and becoming-noisy for libmpv via AudioFocusHelper; the DRM
  ExoPlayer handles focus and noisy itself.
- Brightness handed back on exit and background, re-applied on resume;
  iOS saves and restores the system brightness.
- Swipes start from freshly read levels; window brightness no longer
  guesses from SCREEN_BRIGHTNESS/255.
- Auto-enter PiP on Home while playing (setting, default on).
- PiP actions: localized labels, the real seek step, vector icons.
- MediaSessionCompat while the player is open; Space and arrows on Android
  with a hardware keyboard.
- Desktop: re-apply volume and mute on every new controller, persist them
  in Hive, allow up to 150% on mpv via volume-max.
- Retrying the same source shows the switch screen with the attempt and
  Stop (ServerSwitch.retrySame); TV focuses Stop.
- Error screen scrolls, has a compact landscape layout, always shows a
  translated headline with the raw detail under Details; Dio timeouts and
  HTTP 4xx/5xx are classified.
- End of stream: engine completion or a playhead stuck near the end
  advances once.
- Tap-tap seeking, double-tap centre play/pause, no tap lag when a double
  tap cannot follow.
- Lock pill fades after 3 s; BACK while locked shows it.
- Swipes from system gesture edges are ignored.
- AniSkip skips go through _seekTo; OK skips on TV; Up Next on TV.
- Native codec fault offers mpv for the session; mpv mid-play failure
  retries once with hwdec=no.
- mpv Auto starts from the connection type and steps down after repeated
  stalls, with Undo.
- Vertical clips turn the phone to portrait.
- Player notices are a top-centre glass toast; skip pill and error buttons
  use the player glass style.
# Conflicts:
#	lib/features/detail/presentation/pages/player_page.dart
- Watch Later / Watched: persisted outbox of pending add/remove ops,
  coalesced per item and replayed oldest first before a fetch replaces the
  cache and on connectivity regain; unsent ops are laid over the server
  copy. A failed fetch with no cache shows an error with Retry.
- Alternate-source sheet: one shrink-wrapped CustomScrollView with
  SliverGrid/SliverList so only visible tiles build; posters decode at
  tile width x dpr (also in the relink sheet).
- Always-on blurs (classic nav bar, floating pill, home top bar, search,
  My List, profile and view-all headers) fall back to a translucent fill
  when GlassPowerReading.reducesEffects (Go/low RAM, GL ES 2, Battery
  Saver).
- HistoryService: decoded-row identity cache plus sorted-list memo,
  invalidated on writes and box events; count without decoding.
- View-all/actor grids: loadMoreFailed footer with Retry and exponential
  backoff for scroll-driven requests. Pull-to-refresh on History.
- Decode sizes for comment avatars, shorts images, aniyomi logo and local
  posters; remove the unused lib/features/my_list/my_list duplicate.
- Off Android: rotating local crash log under app support, shown in the
  log viewer and appended to support diagnostics; friendly release
  ErrorWidget with Go home.
- Tablets/foldables: no portrait lock at shortestSide >= 600, nav rail on
  expanded-width mobile, grids pass context to the responsive delegate.
# Conflicts:
#	assets/translations/ar.json
#	assets/translations/ckb.json
#	assets/translations/de.json
#	assets/translations/en.json
#	assets/translations/es.json
#	assets/translations/fr.json
#	assets/translations/hi.json
#	assets/translations/id.json
#	assets/translations/ku.json
#	assets/translations/nl.json
#	assets/translations/pt.json
#	assets/translations/ru.json
#	assets/translations/tr.json
#	assets/translations/uz.json
#	assets/translations/vi.json
#	assets/translations/yue.json
Flutter's own deep linking is off on Android and iOS, so DeeplinkService
is the only thing that handles a link. It opens the target over /main
with typed args, so back returns Home, and holds a link that lands
during splash, onboarding or the profile picker until they finish.

/play links carry ep and t through DetailArgs to the episode list and the
player's start position. The provider is passed per navigation instead of
being saved as the current source. Routes that need an extra redirect to
/main without one. The share link builder can emit ep and t for the player.

The manifest also opts in to OnBackInvokedCallback for predictive back.

The first download started without notification permission on Android
offers the priming sheet, with download-specific copy in all locales.
The bloom pages follow the back gesture on Android, and the theme pins
the predictive back transition. The launcher icon gets a monochrome
layer. The unread-notifications poll stops while the app is in the
background and catches up on return.
The chips and the desktop settings nav use HoverTap, so a remote or the keyboard can reach them. The mouse's back button dismisses the topmost route, as Escape does. Drops an unused import analyze flagged.
@professorDeveloper
professorDeveloper merged commit 9598613 into release/3.2.1 Nov 9, 2026
4 checks passed
professorDeveloper added a commit that referenced this pull request Nov 10, 2026
… downloads and reconnect fixes (#46)

* Preview cache: one prune at a time

Each new episode folder started a prune without waiting. Two running side
by side picked the same oldest folders; the second delete threw, and the
catch around the whole loop gave up on the rest, leaving 25 of 24. That
was the flaky preview_grid_test that turned master's CI red. Prunes are
chained now, and each delete is guarded on its own.

* Desktop player: a Telegram-style corner player inside the app

On macOS, Windows and Linux the player can now dock into a small floating
box in a corner of the app window. It keeps playing while the viewer moves
around Home, Search, a detail page or anything else, because it lives in a
layer above the Navigator (PlayerDockHost, in MaterialApp.router's builder).

Nothing is handed over or restarted. /player now builds PlayerRoute, which
gives PlayerPage a GlobalKey. Docking pops the route, and the same State
moves into the host in the same frame. Expanding pushes /player again, and
the route takes the key back. History ticks, auto-advance, subtitles, party
sync and retries carry on as before, and closing the corner player disposes
the page normally.

- The corner box can be dragged, and on release it snaps to the nearest
  corner, taking the fling into account. Its inner edges resize it, between
  240 px and 60% of the window. The corner and width are saved.
- Hover controls: close, back to the full player, the title, previous and
  next episode, play/pause, 10-second steps when there is room, the time,
  and a timeline that seeks on click or drag.
- I or the PiP button docks the player. Shift+I or a long press keeps the
  old whole-window always-on-top mini, which is still the only way to watch
  over other apps.
- While docked, the keyboard stays with the page underneath. Clicking the
  corner player takes the keyboard. Esc or a click elsewhere gives it back
  without closing the video. I expands.
- Opening another title closes the docked one first. Signing out,
  switching profile and onboarding close it too.
- Fix: in the always-on-top mini window, regaining focus cleared _isPip and
  drew the full control bar into the 426-px window.

Mobile and TV are unchanged. PlayerDock.supported is desktop-only, and the
phone PiP paths are untouched.

* Downloads: judge a web page by its bytes, not its Content-Type

'The source sent a web page instead of the video' failed whole episodes
on vidapi and anikoto. Their CDNs serve real MPEG-TS segments as
page-N.html / seg-N.html with Content-Type text/html; the player never
reads that header and plays them, but both download engines refused
them by it.

- Kotlin and Dart engines read the first bytes and refuse only markup
  (<!doctype html, <html, <head, …); TS, fMP4, MKV, QuickTime and
  encrypted segments pass. A real web page fails once, without retries.
- classify() no longer calls a network error 'web page' because a
  segment URL ends in .html.
- A direct download is probed at tap time and refused there when it is
  a web page; a probe with no answer never blocks.
- Mirrors under a WebView directive are sniffed instead of being taken
  as direct, and a locally proxied stream offers its upstream URL.

* Player: recover from dropped connections, never load forever

- libmpv reconnects a dropped HTTP/HLS stream itself, and a drop is
  only fatal once playback actually stops; then the player retries at
  the same position a few times before saying anything. 'Connection
  reset by peer' after two minutes no longer ends the episode.
- Every server gets a start deadline (longer for torrents and WebView
  sniffing). Past it the player moves to the next server with a short
  notice, and when none plays it says so instead of 'Video loading…'
  forever. ExoPlayer's initialize() is bounded too.
- Library error sentences are mapped to localized messages.
- A labelled or remembered quality is pinned on ExoPlayer instead of
  ABR starting at the lowest rung, and Auto no longer claims the size
  it started at.
- media_kit: the picture is forced to redraw once the texture exists, so
  a stream no longer stays black until the first seek.

* CloudStream: search plugins that only implement paged search

MovieBox overrides only the paged search, and our host called the plain
one, which CloudStream leaves as NotImplementedError. The host now falls
back to the paged search (and the other way round).

* Translations for the reconnect notice, the all-servers-failed message and the corner player; Continue Watching keeps up while docked

* Downloads: a page on one mirror no longer blocks the others

The tap-time probe failed the whole episode when the default mirror served a
web page, even with a working mirror the choice sheet would have offered. Now
only when every mirror does; otherwise the pages are dropped from the choice.
The film page shows the web-page message instead of "play it first", and a
stream played through the media-segment relay offers its upstream url for
download rather than the 127.0.0.1 address that dies with the player.

* Player: a file cut short by the network reports it; live reconnects at once

An end of file is judged before playhead movement: the cache carries the
playhead past the drop, so a file the connection ended early always looked
recovered and stopped silently mid-episode. The last tcp error is kept for
that judgement. A live stream is no longer held through the 25 s grace once
playing, and an end of a stream without length after a drop is fatal, so it
reconnects. Torrents opened from torrent search get the torrent start budget.

* Corner player: a route that gave its page away never builds it again

Restoring, closing or opening another title while the left /player route was
still animating out rebuilt the page there too: the same GlobalKey twice, or
the page pulled into a dying route. Signing out and switching profile now
close the corner player first, so its last history save lands before the
account's rows are wiped instead of after.

* Repo: drop chat transcripts, a Gradle report and unused images

Two exported chat transcripts and a Gradle problems report were committed by
mistake, along with banner and screenshot images nothing links to any more.
The transcripts stay on disk, now ignored; horizontal-banner.png stays because
the v1.0-v3.0 release notes still show it.

* Desktop PiP: float above other apps and full-screen Spaces when leaving

Leaving Sozo while a video plays now shrinks the player into the mini
window, which floats like Chrome's and Telegram's picture-in-picture: on
every Space, beside other apps' full-screen windows, above floating panels,
with no title bar, and with hover controls even while another app is in
front. Coming back from the mini puts a docked player back in its dock.

* Player: a dropped audio or video packet no longer stops playback

mpv reports "Error decoding audio." for a single bad packet and carries on;
the player treated it as fatal and showed an error over a playing video.

* Bottom bar: open the tapped tab in Arabic

The glass capsule's tab Row follows the ambient direction, so in Arabic it
laid the tabs out right to left while the package's tap, drag and indicator
maths kept counting from the left edge. Every tap opened the mirror-image
tab: Home went to Profile, Search to Downloads.

Build the bar left to right in every locale and, in RTL, hand it the tabs
reversed with the index and the callback mirrored. Home stays on the right
in Arabic. liquid_glass_widgets fixed it the same way upstream after 0.18;
upgrading across that major is not a patch-release change.

* Glass: one core for drawn Liquid Glass

Brings the drawn glass from satashkent-staff, the same liquid_glass_renderer
pin (0.2.0-dev.4) and the rules it was tuned under:

- GlassGroup/GlassShape/GlassSurface: refraction where Impeller runs it,
  a blur under a thicker tint where it cannot, an opaque fill under Reduce
  Transparency. One recipe per kind, dark only, since Sozo has one theme.
- Held to its tint while its route moves, while the keyboard moves, and
  while one of the phone's own views stands under it, where the shader
  would draw a dark block.
- GlassSense (iOS, soplay/glass) reports Reduce Transparency; GlassPower
  (Android, soplay/glass_power) reports an Android Go phone, which draws
  the blur instead of the refraction.
- MediaGlass: clear glass for controls over video, with the player's 0.45
  black laid in it so a white glyph keeps 3:1 over a white frame. iPhone
  only; every other platform keeps the black plate it has.

Nothing draws it yet.

* Player: clear glass under the controls on iPhone

The top bar, the centre transport and the bottom row now stand on
MediaGlass instead of a black disc: the same black, laid in clear glass
that bends the frame at its rim. Each row is one MediaGlassLayer, so the
whole overlay reads the picture three times, not once per button.

Android, TV and desktop draw exactly what they drew before.

* Player: glass lock pill, haptics, system spinner on iPhone

- The lock pill stands on MediaGlass with the deeper 0.6 dimming its
  words need.
- Locking and unlocking give a firm tap: the controls vanish with the
  lock, so nothing on screen confirms it. A double-tap seek ticks, since
  the ripple lands on the far side of the thumb. Phones only.
- Locking was the same five-line lambda in two places; now _lock().
- A stall shows CupertinoActivityIndicator on iOS, as AVKit does over a
  stalled video; Material's ring everywhere else.

* Downloads: fetch the file the way the player fetches it

A download started from the episode list or the detail page went out with
only the headers the source declared - usually just a Referer - so the
Android service asked under Dalvik's agent and the in-process engine under
dart:io's. Hosts that answer a bare client with a page failed those episodes
as "a web page instead of the video" while the same episode played. The
player's header rule now lives in mediaRequestHeaders and the transfer and
the tap-time probe both use it.

* Episodes: one season at a time for sources that list them all

A CloudStream series hands every season over in one list, numbered within
each season. The app dropped the season and told episodes apart by number,
so Lupin's seasons sorted into each other (Chapter 1, Chapter 6, Chapter 1)
and S2E1 shared an id with S1E1. Episodes now keep their season; a run whose
seasons share numbers is numbered through in season order (season 1 keeps
its numbers), the list shows one season at a time behind season chips, rows
and the player show S2 E1, and switching source matches season and episode.

* My List: sort by recently added, oldest added or title

Asked for in support. The pick is remembered on the device. Also drops an
unneeded import from the glass test.

* Relations: films and series get their franchise and TMDB's picks

The tab asked only AniList and took its first search hit, so a film or a
series either had no relations or showed an anime that shared a word with
its name. A title that is not anime now gets the rest of its film series and
TMDB's recommendations (through the backend proxy), and the AniList path
takes a hit only when its title really matches.

* Other sources: Auto plays from the first source that has it

The sheet listed every source that had the title and left the walk to the
viewer: pick one, and when it had the show but not the episode, pick the
next. Auto does the walk - corrections first, then the matches it trusts by
score, each source as it answers - and opens the first one that plays.

* Downloads: Save to Downloads writes the video of an HLS download, not its playlist

An HLS download is index.m3u8 and its segments, and the exporter copied the
one file it was given: an 80 KB Baththa.m3u8 in Downloads/Sozo and the 655 MB
film still in the app. The segments are now joined into one .ts, or .mp4 for
fragmented MP4, with AES-128 decrypted. A separate audio playlist or SAMPLE-AES
is refused with a reason rather than saved broken.

* Reader: pinch zoom in the continuous mode

Only the paged mode could zoom. The list stops scrolling while two fingers are
down, or a pinch whose fingers move up or down reads as a scroll.

* My List: a catalogue title shows the catalogue's name, not cat:anilist

* Seek preview: position / total on the card, and a host that stops answering is a failure

The card read "00:49 +1:25"; it now reads "00:49 / 02:07".

Timeouts and refused opens were not counted, so a dead host left the card on a
skeleton for good and the warmer walked two full passes over it. They count
now, and the warmer stops after 6 in a row. The byte count of the scrub cache
no longer drifts when a frame moves to the grid.

A progressive file gets the same 15 s open and 6 s frame as HLS: a slow host's
index took longer than 5 s, and a timeout discards what was read.

The grid is not filled when a VTT arrives during the wait, and a download warms
the full grid on mobile data.

VTT cues may leave the hours out, the parser is testable, a sheet shows the
skeleton while it loads, and the first two sheets are precached.

* Seek preview: stop losing frames on slow hosts, and try a second decoder

Whole streams never showed a picture, while the player was playing them.

- Too little time. A first frame is a playlist, a segment and a decode, and 5 s
  was never enough for a 2.3 MB segment on 2 Mbit/s. Each try was abandoned and
  the next paid for a segment again. Now 18 s native, 20 s in Dart.
- A lifted finger threw away the frame being fetched, so on a slow host no drag
  ever showed one. It now lands in the cache and the next drag finds it.
- The retriever was released on every lift and read the file's index again each
  drag. It is kept for a minute, and an open that took long still counts.
- A retriever that refuses a stream, hangs or only gives nulls now hands over to
  Media3's extractor, which also reads HLS that a source mislabelled.
- A failed proxy registration or playlist read was cached for 20 minutes.
- The grid gave up for good on a host that was down. It rests and tries again,
  and waits for the duration when the engine does not know it yet.
- A sprite sheet that never answers falls back to the decoder.
- The proxy now recognises a playlist served as text by its first bytes; its
  absolute segment addresses used to go out with no headers.

Not checked on a device: the Kotlin changes are compiled, not run.

* Player: a source that says nothing about `accessible` is one it may try

The flag means a probe refused the source. Providers do not probe, so it is
usually absent, and reading absent as false left SourceLadder with nothing: the
first mirror played, its failure ended on the error screen with the others
untried, and the remembered quality was ignored. Real logs show one url retried
until the error, on primesrc and on Coflix. CloudStream, Aniyomi, Mangayomi and
Jellyfin always sent true, so only they ever walked.

* Support: a follow-up sends a newer report where one was attached

Diagnostics were taken once, when the ticket opened, so "it still does not play"
reached support with a log from before the attempt, or none. Only on a ticket
that opened with diagnostics: the switch on the new-ticket screen stays the
viewer's choice.

* Subtitles: say so when a translation stops part way

Past the first slice the track stayed with the rest of the file still in the
source language, and no message.

* Translations: the source check's message and button were swapped

The Hide button held the long no-connection sentence, and the message held
"Hide sources that are down", in every language. Also a literal \u201c on screen
in the TV link message.

* Vietnamese: the interface, and subtitles found and translated in it

vi.json is machine-written and not yet read by a native speaker. Flutter and the
system fonts already cover Vietnamese, so nothing else is needed for it to show.

Subtitle labels named Vietnamese, vie or Vietsub now match. A subtitle in the
viewer's language beats the source's default track, and one-tap translate uses
a human subtitle in the wanted language rather than machine-translating English.

* Player: leave spliced-in ads out, unwrap image-wrapped segments, respect hard subs

KKPhim splices a 30 s gambling ad into every title. A source can now name such
segments, and the local proxy drops them from the playlist. Checked through the
real proxy against a live title: 6073.0 s becomes 6042.8 s, and it plays across
the join.

VSMOV stores MPEG-TS behind real PNGs. The proxy finds the stream behind the
picture and its padding, including when the player opens with Range: bytes=0-,
as both engines do. Checked against a live title: FFmpeg reads 1080p H.264 and
decodes it; without the proxy it reads a 0x0 PNG. __sozoCaps tells the served
script this build can do it.

A source can say which language is burned into the picture, so auto-translate
does not draw a second line over it. Subtitle search says what the viewer reads,
so the server can look further for it.

* Vietnamese: long titles keep their tone marks

A Vietnamese capital carries two marks and reaches about 1.03 em above the
baseline. Text cut off with an ellipsis is clipped to its line box, so at a height
of 1.08 a long title lost them. Rendered with real Roboto: the Ẳ of a long banner
title showed as Ă, which is another word, and at 1.25 it is whole. Only Vietnamese
gets the extra room; other languages keep the design.

* Windows Anime4K, local-proxy range handling, and honest source health (#47)

* Anime4K: Windows takes ';' between shader files, not ':'

mpv's path lists are separated by ':' on Unix and by ';' on Windows, where a
':' is the drive letter's own. Joined with ':' there, a three-file chain
arrived as one impossible filename —

  Cannot open file 'C:\…\Anime4K_Clamp_Highlights.glsl:C:\…_x2_VL.glsl':
  Invalid argument

— and mpv took video output down with it, so the episode was a black screen
behind a playback error. Windows only, and only for the viewers who had
turned Anime4K on; found in a support log from one of them (ticket
6ae742f9), who reported it as "no image at all, just the retry error".

The separator now lives in one named function next to the presets, where the
filenames it joins are, and the tests cover both platforms and the
single-shader case.

* Local HLS proxy: answer a range request the way a player expects

The proxy stands between the player and a CDN that only answers signed,
transformed requests, and it was loose about the half of HTTP that media
players lean on hardest: a HEAD was served as a GET, a ranged request could
come back 200 where the caller asked for 206, Content-Range and
Accept-Ranges were sometimes absent, and a rewritten playlist went out with
no Content-Length. libmpv and ExoPlayer tolerate all of it. AVFoundation —
which is what plays every stream on iOS, since resolvePlayerEngine() has no
libmpv to offer there — is entitled not to.

Twelve tests drive the proxy the way a player does: HEAD on a playlist and
on a segment, "Range: bytes=0-", "Range: bytes=0-1", no range at all, the
same segment twice, and an upstream that answers a ranged request with a
whole file. Six of them failed before this change.

What this is NOT: the cause of uzmovi not playing on iOS. The integration
test added here plays the live uzmovi stream through the real resolve, the
real proxy and the real native player on an iOS target and prints what the
player says. It reaches readyToPlay with the correct 1:29:19 duration and
plays past two seconds — with this change AND with the code as it shipped in
3.2.1. So these are a protocol-correctness debt paid off, and the iOS report
still needs a log from the device that saw it.

* Sources: stop calling a source down on somebody else's evidence

Two witnesses report on a source and they do not have equal standing. The
server sweeps every extension's site from a datacenter; this phone runs the
source's own code over a residential address. For a Sozo cloud source those
are the same question — the catalogue reaches the app through the backend
either way. For a CloudStream, Aniyomi, Manga, Mangayomi or Jellyfin source
they are not, and a datacenter's result says nothing about the phone's leg.

In the live health report there were 930 extension verdicts, 229 of them
"dead", and 96 of those were a TLS handshake, a timeout or an unreachable
address — the three symptoms a site shows a datacenter and not a phone.
Ninety-six sources wore a red Down badge that nothing had established on the
device they actually run on. Those badges now wait for a check from here,
which the row's long-press offers; a parked or non-resolving domain is a fact
about the domain and still stands wherever it was found.

Three more things the same screen was getting wrong:

- An empty catalogue counted as health. A 200 with an empty list is the
  commonest shape of a blocked site — kisskh answers the backend exactly
  that way today — so zero items now reads as a problem.
- "Hide down sources · 7" took four rows away: the count was tallied over
  every row of the tab while the hiding ran over the rows left after the
  scope and language filters, and the source in use is never hidden. One
  function now answers both from the same list.
- The quick-switch sheet — the one place a reader goes to get OUT of a
  broken source — still drew badges and counted from the raw store, so it
  kept steering people away from sources that work here. It asks the same
  policy as the Sources screen now.

* Close the gaps the review left open: proxy ranges, and a title a source gave up on (#48)

* Local HLS proxy: never serve a playlist from a slice, never a wrapper raw

Three gaps an adversarial review found in the range handling, each closed
with a test that fails against the previous code.

A playlist served at a path that does not end in .m3u8 — /playlist,
master.txt — still had the client's Range forwarded, so a range-honouring
host returned a 206 slice, the sniffer recognised #EXTM3U inside it, and the
proxy rewrote and served that slice while declaring it to be the whole file.
A player was handed a manifest cut off mid-segment-list and told its length
was ten bytes. Once the sniffer knows it is holding a manifest, a partial
body is dropped and the file fetched again with Range stripped, so the
rewrite always works on the complete text.

An image-wrapped segment answered a Range with a 200, the whole body and
`Accept-Ranges: none` — the exact protocol error the comments here say
AVFoundation abandons a stream over. The unwrapped payload now goes out
through the same buffered path the rewritten playlists use: a real
Content-Length, a correct 206 with Content-Range, and a 416 past the end.
The Range is also kept off the upstream for image paths, because a host that
honoured a two-byte probe would have returned two bytes of PNG signature and
the transport-stream offset could never have been found.

A short upstream used to kill a committed response mid-flight: a range of up
to 24 MB — everything a player actually asks for — is collected before any
header goes out, so a host that sends fewer bytes than it declared becomes a
clean 502 instead of a connection that dies with a length it cannot reach.

* A title a source gave up on says so, in the reader's language

The backend has been telling us for a while and nobody was listening. A
title the source has moved into its own app comes back from
/contents/episodes with playable false, a reason code and an empty episode
list, and a failed resolve carries the same code. The app threw the body
away — `if (code == 422) return Failure(Exception('Video URL aniqlanmadi'))`
— so whatever had happened, the reader got one technical sentence in Uzbek
and a Play button that could not work.

Now the three cases the backend distinguishes reach the reader as three
different sentences, in their own language: the source has moved this title
into its own app, the page has no player at all, or this is a serial and its
episodes are elsewhere. No Play affordance on a title the source called
unplayable, and the obvious next step — the same title from another source —
is offered in its place.

Three smaller things in the same screens:

An empty catalogue drew no badge. kisskh answers the backend 200 with an
empty list, which is the commonest shape of a blocked site, so the row
appeared among the problems with nothing to say why. It has a badge of its
own now, for a verdict this phone reached itself rather than a sweep's.

A phone whose clock jumped forward lost its entire check table. Pruning on
every write turned a temporary clock error into permanent data loss, where
before those rows were merely hidden and came back when the clock was
corrected.

runsOnDevice read as id-or-key agnostic and was not: a `csp:` health key
missed the CloudStream prefix and came back as a Sozo cloud source, which is
the one answer that lets a datacenter's verdict stand over a phone's.

* Android glass measured rather than guessed, the system glass on iOS, and tests as a visible check (#49)

* Local HLS proxy: never serve a playlist from a slice, never a wrapper raw

Three gaps an adversarial review found in the range handling, each closed
with a test that fails against the previous code.

A playlist served at a path that does not end in .m3u8 — /playlist,
master.txt — still had the client's Range forwarded, so a range-honouring
host returned a 206 slice, the sniffer recognised #EXTM3U inside it, and the
proxy rewrote and served that slice while declaring it to be the whole file.
A player was handed a manifest cut off mid-segment-list and told its length
was ten bytes. Once the sniffer knows it is holding a manifest, a partial
body is dropped and the file fetched again with Range stripped, so the
rewrite always works on the complete text.

An image-wrapped segment answered a Range with a 200, the whole body and
`Accept-Ranges: none` — the exact protocol error the comments here say
AVFoundation abandons a stream over. The unwrapped payload now goes out
through the same buffered path the rewritten playlists use: a real
Content-Length, a correct 206 with Content-Range, and a 416 past the end.
The Range is also kept off the upstream for image paths, because a host that
honoured a two-byte probe would have returned two bytes of PNG signature and
the transport-stream offset could never have been found.

A short upstream used to kill a committed response mid-flight: a range of up
to 24 MB — everything a player actually asks for — is collected before any
header goes out, so a host that sends fewer bytes than it declared becomes a
clean 502 instead of a connection that dies with a length it cannot reach.

* A title a source gave up on says so, in the reader's language

The backend has been telling us for a while and nobody was listening. A
title the source has moved into its own app comes back from
/contents/episodes with playable false, a reason code and an empty episode
list, and a failed resolve carries the same code. The app threw the body
away — `if (code == 422) return Failure(Exception('Video URL aniqlanmadi'))`
— so whatever had happened, the reader got one technical sentence in Uzbek
and a Play button that could not work.

Now the three cases the backend distinguishes reach the reader as three
different sentences, in their own language: the source has moved this title
into its own app, the page has no player at all, or this is a serial and its
episodes are elsewhere. No Play affordance on a title the source called
unplayable, and the obvious next step — the same title from another source —
is offered in its place.

Three smaller things in the same screens:

An empty catalogue drew no badge. kisskh answers the backend 200 with an
empty list, which is the commonest shape of a blocked site, so the row
appeared among the problems with nothing to say why. It has a badge of its
own now, for a verdict this phone reached itself rather than a sweep's.

A phone whose clock jumped forward lost its entire check table. Pruning on
every write turned a temporary clock error into permanent data loss, where
before those rows were merely hidden and came back when the clock was
corrected.

runsOnDevice read as id-or-key agnostic and was not: a `csp:` health key
missed the CloudStream prefix and came back as a Sozo cloud source, which is
the one answer that lets a datacenter's verdict stand over a phone's.

* Android glass: decide on what the phone measures, not what it was sold as

GlassPower asked ActivityManager.isLowRamDevice and nothing else — the flag a
maker sets when the phone ships as an Android Go device. It was wrong in both
directions: a 2019 mid-range phone with 6 GB and a pre-Vulkan driver carries no
flag, drew the refraction and dropped frames; a current 4 GB phone whose maker
sets it lost the glass for nothing. The channel now reports what can actually
be read — total memory, API level, Vulkan 1.1 in the driver, the GL ES major,
the panel's refresh rate, Battery Saver, the core count — and the decision is a
pure function in Dart with the order of refusal and the reason for each written
down beside it. A missing or malformed field keeps its generous default, so a
half answer never takes the glass away.

Battery Saver is the one reading that changes while the app runs, so it is
pushed over the channel as it turns and re-asked on resume instead of being
read once at launch and believed all evening.

Behind the gate there is now a measurement: GlassStepDown watches each frame's
whole span against the panel's own budget while a refraction is on screen, and
drops to the blur for the rest of the run when a sustained run of frames
misses. No flag a phone carries says whether its GPU can bend the screen sixty
times a second; the frames it draws do. A warm-up, a sliding window and slack
keep a shader compile or a decode from deciding, the verdict latches so glass
does not come and go as pages scroll, and it never arms in a debug build, where
the JIT and the assertions miss frames on the fastest phone made.

Two drawing faults found on the way, both proven failing-first:

BackdropGroup was never placed anywhere, so the blur path's BackdropGroup.of
returned null every frame and every shape took its own full read of the screen
behind its clip — three reads for a bar with a pill and a lens, where the same
glass refracting takes one. A GlassGroup now provides one.

The blur path dropped the recipe's saturation, which is the number that makes a
poster still read as a poster through the bar. The phones that fall back to the
blur were getting a grey version of a different design rather than a plainer
version of the same one.

The frame cost is reasoned, not measured: there is no Android device in this
session, so what this removes from the frame is stated and what it is worth in
milliseconds is not.

* iOS: the system's own glass, available but not yet wired in

The app approximates in a shader what iOS now draws itself. This adds the real
thing — a UIKit platform view over UIVisualEffectView with the glass effect the
running OS provides, a capability probe over the existing soplay/glass channel
so Dart chooses from what the phone reported rather than from a version string,
and a fall back to the system blur material on an OS without the glass.

Deliberately NOT connected to GlassGroup. Nothing in the app renders it yet, so
this commit cannot change a pixel of what ships; the integration point is a few
lines in glass_surface.dart and a new GlassMode, and that file belongs to work
in progress elsewhere. Wiring it is a decision to take with the tab bar in
front of us, not a side effect of adding the capability.

What a platform view costs here is written into the widget's documentation,
because it decides where this is the right answer: it composites outside the
Flutter layer tree, so it cannot be freely rotated, clipped or animated the way
the shader can, it does not appear in Flutter goldens, and it brings its own
hit-test and ordering rules. The conclusion recorded there is that the shader
stays right for the moving pieces — the lens that follows a finger — and the
system glass belongs on the still surfaces.

Two things are open, and neither is hidden:
- On an iOS 26+ phone Dart can still choose systemMaterial through the branch
  for a style the phone did not report, and the Swift side does not honour that
  choice. It needs a decision about which side owns the fallback.
- Nobody has looked at a pixel. The corner is a layer cornerRadius with
  clipsToBounds, which masks the glass rather than shaping it, and that is the
  most likely way this looks wrong on screen once it is used.

The integration test runs on a simulator and asserts the native side is really
there: it was first written so that it passed with the view registration
commented out, which is the easiest way for a test like this to be worthless,
and it now fails in that state.

* CI: make the tests a check of their own

flutter test was the last step of the analyze job, so a pull request's check
list said "analyze" and a reader could not tell whether the suite had run — and
because an analyzer error ends the job early, a red "analyze" meant either the
tests failed or they never started, one signal for two different situations. It
misled me today on #47.

The trade is in the file: a separate job pays for its own checkout and its own
pub get, about a minute, in exchange for a check whose name is the claim it
makes.

* Glass gate test: use a recipe that is actually committed

The blur-lift cases reached for GlassRecipe.androidBar, which exists only in
the tab-bar work that has not landed yet. With that file in the working tree
the suite passed here and the analyzer failed in CI, which is the worst
version of this mistake: green locally, red where it counts, and for a reason
that has nothing to do with what the test is about.

The test needs a recipe that asks for a colour lift, and the regular glass is
one (saturation 1.3). It says the same thing and compiles against what is in
the branch.

* Hindi: the interface, and subtitles found and translated in it (#52)

hi.json is machine-written and not yet read by a native speaker. Flutter ships
Hindi's framework strings and date data, so nothing is borrowed.

The app bundles no font. Roboto has no Devanagari, so Hindi relies on each
platform's system fallback. Rendered with Noto Sans Devanagari and Kohinoor
Devanagari, a clipped line at height 1.08 cuts the marks above and below, so
Hindi joins Vietnamese in the line height that leaves room for them.

Subtitle labels named Hindi, हिन्दी, hin or hi-IN now match. A bare HI only
counts when it leads the label, because English (HI) means hearing impaired.
Hindi is offered as a translation target: Azure, DeepL and Google all list it.

* Build the release draft from docs/releases and its screenshots (#55)

* Build the release draft from docs/releases and its screenshots

A version with docs/releases/v<version>.md now gets those notes in its draft.
Relative images link to the released commit, a missing image is removed and
reported, and the download table lists only the files attached. Versions
without a notes file keep the generated notes.

* Add the v3.2.1 release notes

* Avoid the OpenGL video interop on Linux (#54)

A Linux laptop with an NVIDIA GPU showed a solid blue picture with working
audio on every source. Decoded frames now reach the renderer through system
memory instead of the GPU interop.

* Add the Linux blue picture fix to the 3.2.1 notes (#56)

* 3.2.1: voice search, manga page translation, Watch Party, XP, episode comments and more (#69)

* Voice search that explains failures, follows the app language, and answers the TV remote

Speech is initialised on the first mic tap instead of when search opens, and
the button no longer disappears. The dialog now shows distinct states for no
speech, a refused microphone permission (with a link to the app's settings),
a missing recogniser, and network or recogniser errors, with a retry where it
can help. The recogniser locale follows the app language when the device has
it. The mic stays next to the clear button once the field has text, and the
remote's search and voice keys start voice search on Android TV.

* Mangayomi: per-source install, remove, update and enable

Pick which sources to install from a repo, remove or disable single
sources, review updates before applying them, and filter the installed
list by type and language. Removed sources stay out of later refreshes,
and removing a source or repo clears its cached code and settings.
Catalog rows install just their own source.

* Show Auto Resolve progress with stop and a cap

Try another source > Auto now says which source it is checking and how
far through it is, can be stopped, gives up after 20 candidates, and lists
what it skipped and why. The walk lives in AutoResolveWalk so it can be
unit tested.

In the player, an automatic ladder step uses the server switch overlay
(from -> to, attempt x of y against RetryPolicy) with a Stop that leaves
the player on its error screen. Stop is reachable by D-pad on TV.

* Translate manga pages inside the reader

On-device OCR with ML Kit (Android and iOS), bubbles merged from nearby
text blocks, one translateLines request per page with docKey
manga:<chapterUrl>:<page>. A preview offers Apply, Show original and
Cancel; applied overlays are kept per chapter and page in memory and on
disk. The reader settings gain a page translation language and an
optional translate-as-I-read switch.

* Check profile PINs offline, add biometric unlock and a skip-picker setting, shorten the splash

- Keep a salted PBKDF2 verifier per account and profile after the server
  accepts a PIN; use it, with the app lock's backoff, only when the server
  can't be reached. Cleared on sign-out, PIN change or removal, and delete.
- Offer fingerprint / Face ID per profile after the first PIN entry.
- Add 'Ask who's watching on launch' to Manage profiles; off opens the last
  profile, unless it has a PIN.
- Don't re-ask the active profile's PIN within the same run.
- Splash hurries to its fade once the route is known (800 ms minimum) and
  doesn't wait on the profile list when offline.
- Move PIN hashing, backoff and biometrics into shared app_lock helpers.

* Add external Zangetsu repos, run on the device

Repos are added by their index.json link (raw GitHub or gist only) and read
through /extension-repos/zangetsu. Installed sources register as client
providers whose extractor is the composed /extractors/zangetsu-ext bundle.
Sozo's own providers win on any id clash, and sources the backend marks as
built in are shown but never installed.

* Add a live Contributors section to About and a GitHub star prompt

About lists everyone who has committed to the public Sozo repos, from
GET /api/contributors, with the last list cached in Hive for offline.

After seven finished episodes a dismissible card on Home or Detail asks
for a GitHub star. Later snoozes it for twenty more episodes; Star or
Don't ask again ends it. The update dialog gets a small star button.

* Drop a const around a theme colour in the contributors skeleton

* Hide intl's TextDirection in the overlay; drop a redundant null check

* Add episode comments, spoiler hiding and timed reactions

The player gets React and Comments buttons. Comments opens the episode's
own thread (side panel on landscape, TV and desktop, bottom sheet on a
portrait phone). React opens an eight-emoji bar with a short text option
and posts at the current second.

Other viewers' reactions pop up in a lane at the start edge, grouped and
capped at three on screen, with a heatmap strip over the seek bar. Both
can be turned off in player settings.

Comments can be marked as spoilers and are blurred until tapped. A new
setting, on by default, also blurs comments about episodes the viewer
has not finished, using watch history. The episode list shows a comment
count per episode when there are any.

Title-level comments, and apps without this change, behave as before.

* Add XP, levels and profile cosmetics

A progress page with the level ring, recent XP and a locker for avatar
frames, titles and name colours. Watch time goes to the server every ten
minutes while playing and when the app goes to the background, as each
day's running total so a retry adds nothing. Comment authors, social
cards, activity cards and the profile header show the frame, name colour
and level. A level-up is celebrated by the achievement celebration host.
Nothing shows until the server answers /progress.

* Watch party: host controls, friend invites, chat history, better sync

- Lobby host card: room size, host-only playback, lock room, wait for
  everyone, all live over party:settings
- Invite friends sheet on the existing invite endpoint, with per-friend
  cooldowns
- Make host / remove from party from the member bar (tap, long-press or
  D-pad), and a buffering badge per member
- Chat history from party:state plus joined/left/host/removed lines
- party:ping clock offset re-bases each sync so a slow link no longer
  trails the host by its one-way trip
- Guests report buffering; with wait for everyone on, the host holds up
  to 15 s for a guest stuck over 3 s
- A party invite opened while signed out is kept through sign-in or
  first-run setup and opens once afterwards
- apple-app-site-association now lists /party/*, /link/* and /pair/*

* Match the episode repository with a pattern

An is-check does not promote CommentsRepository to an unrelated
interface, so the scoped calls did not resolve.

* Play through the window in the scheduler test

Jumping from 10 s to 13 s in one tick is a seek, which correctly
restarts the window.

* 3.2.1: UI polish for the new features, support send fix (#70)

* Voice search that explains failures, follows the app language, and answers the TV remote

Speech is initialised on the first mic tap instead of when search opens, and
the button no longer disappears. The dialog now shows distinct states for no
speech, a refused microphone permission (with a link to the app's settings),
a missing recogniser, and network or recogniser errors, with a retry where it
can help. The recogniser locale follows the app language when the device has
it. The mic stays next to the clear button once the field has text, and the
remote's search and voice keys start voice search on Android TV.

* Mangayomi: per-source install, remove, update and enable

Pick which sources to install from a repo, remove or disable single
sources, review updates before applying them, and filter the installed
list by type and language. Removed sources stay out of later refreshes,
and removing a source or repo clears its cached code and settings.
Catalog rows install just their own source.

* Show Auto Resolve progress with stop and a cap

Try another source > Auto now says which source it is checking and how
far through it is, can be stopped, gives up after 20 candidates, and lists
what it skipped and why. The walk lives in AutoResolveWalk so it can be
unit tested.

In the player, an automatic ladder step uses the server switch overlay
(from -> to, attempt x of y against RetryPolicy) with a Stop that leaves
the player on its error screen. Stop is reachable by D-pad on TV.

* Translate manga pages inside the reader

On-device OCR with ML Kit (Android and iOS), bubbles merged from nearby
text blocks, one translateLines request per page with docKey
manga:<chapterUrl>:<page>. A preview offers Apply, Show original and
Cancel; applied overlays are kept per chapter and page in memory and on
disk. The reader settings gain a page translation language and an
optional translate-as-I-read switch.

* Check profile PINs offline, add biometric unlock and a skip-picker setting, shorten the splash

- Keep a salted PBKDF2 verifier per account and profile after the server
  accepts a PIN; use it, with the app lock's backoff, only when the server
  can't be reached. Cleared on sign-out, PIN change or removal, and delete.
- Offer fingerprint / Face ID per profile after the first PIN entry.
- Add 'Ask who's watching on launch' to Manage profiles; off opens the last
  profile, unless it has a PIN.
- Don't re-ask the active profile's PIN within the same run.
- Splash hurries to its fade once the route is known (800 ms minimum) and
  doesn't wait on the profile list when offline.
- Move PIN hashing, backoff and biometrics into shared app_lock helpers.

* Add external Zangetsu repos, run on the device

Repos are added by their index.json link (raw GitHub or gist only) and read
through /extension-repos/zangetsu. Installed sources register as client
providers whose extractor is the composed /extractors/zangetsu-ext bundle.
Sozo's own providers win on any id clash, and sources the backend marks as
built in are shown but never installed.

* Add a live Contributors section to About and a GitHub star prompt

About lists everyone who has committed to the public Sozo repos, from
GET /api/contributors, with the last list cached in Hive for offline.

After seven finished episodes a dismissible card on Home or Detail asks
for a GitHub star. Later snoozes it for twenty more episodes; Star or
Don't ask again ends it. The update dialog gets a small star button.

* Drop a const around a theme colour in the contributors skeleton

* Hide intl's TextDirection in the overlay; drop a redundant null check

* Add episode comments, spoiler hiding and timed reactions

The player gets React and Comments buttons. Comments opens the episode's
own thread (side panel on landscape, TV and desktop, bottom sheet on a
portrait phone). React opens an eight-emoji bar with a short text option
and posts at the current second.

Other viewers' reactions pop up in a lane at the start edge, grouped and
capped at three on screen, with a heatmap strip over the seek bar. Both
can be turned off in player settings.

Comments can be marked as spoilers and are blurred until tapped. A new
setting, on by default, also blurs comments about episodes the viewer
has not finished, using watch history. The episode list shows a comment
count per episode when there are any.

Title-level comments, and apps without this change, behave as before.

* Add XP, levels and profile cosmetics

A progress page with the level ring, recent XP and a locker for avatar
frames, titles and name colours. Watch time goes to the server every ten
minutes while playing and when the app goes to the background, as each
day's running total so a retry adds nothing. Comment authors, social
cards, activity cards and the profile header show the frame, name colour
and level. A level-up is celebrated by the achievement celebration host.
Nothing shows until the server answers /progress.

* Watch party: host controls, friend invites, chat history, better sync

- Lobby host card: room size, host-only playback, lock room, wait for
  everyone, all live over party:settings
- Invite friends sheet on the existing invite endpoint, with per-friend
  cooldowns
- Make host / remove from party from the member bar (tap, long-press or
  D-pad), and a buffering badge per member
- Chat history from party:state plus joined/left/host/removed lines
- party:ping clock offset re-bases each sync so a slow link no longer
  trails the host by its one-way trip
- Guests report buffering; with wait for everyone on, the host holds up
  to 15 s for a guest stuck over 3 s
- A party invite opened while signed out is kept through sign-in or
  first-run setup and opens once afterwards
- apple-app-site-association now lists /party/*, /link/* and /pair/*

* Match the episode repository with a pattern

An is-check does not promote CommentsRepository to an unrelated
interface, so the scoped calls did not resolve.

* Play through the window in the scheduler test

Jumping from 10 s to 13 s in one tick is a seek, which correctly
restarts the window.

* Support: never leave a request spinning; player: no raw mpv address as the error

A support request that failed with anything but a server refusal (a file
that went away, a reply the app could not read) left Send spinning, so a
viewer could not tell whether it went (ticket 6aee1aca). Every failure now
ends with a message, said inline and in a snackbar, and a dropped connection
says so. In the player, mpv's "Failed to open <url>" is shown as the blocked
source message instead of the raw line.

* 3.2.1: UI polish for the new features

- Progress: retry on the unavailable state, directional padding, locker
  items focusable on TV and marked selected, tokens instead of raw greys.
- Level ring scales its label down at large text sizes, reads as one
  label and skips the sweep with reduced motion.
- Level-up dialog: localised barrier label, scrolls on short screens,
  spaced buttons, the primary action focused on TV.
- Framed avatars draw their frame outside the avatar, so framed and plain
  avatars line up in lists, comments and headers.
- Profile header: the level link is a HoverTap, so it gets the focus ring.
- Contributors: a retry beside the load error; one semantics node per
  person instead of two buttons.
- Star prompt: the rating gold token.
- Episode comments side panel slides from, and pads for, the correct edge
  in RTL.
- Reaction picker: 48 dp buttons, a label on Send.
- Watch party: directional paddings, tooltips on the room size stepper,
  switches styled like Settings, member tiles focusable on TV, the invite
  button is the shared secondary button, a label on chat Send.
- Mangayomi and Zangetsu pages: theme tokens for error, success and greys;
  Zangetsu filters use the shared chip rows; the picker's select buttons
  shrink instead of overflowing; filter chips report their selection.
- Manga translation preview: the action bar wraps on narrow phones, Apply
  focused on TV.
- Auto Resolve: Stop and Try again take focus on TV when the row stops
  being focusable.
- Voice search: dialog radius matches the app's dialogs; Cancel focused
  on TV while listening.
- Player settings: the spoiler toggle no longer shares the incognito icon.

* iOS 26 player: native UIKit Liquid Glass controls (#71)

* Player: hold the controls' glass to its tint while they fade

On an iPhone the controls stand on refracting glass. A FadeTransition over
it composites the glass and the picture under it on every frame of the
220 ms fade, each time the controls show or hide. GlassHoldWhile puts the
glass down to its tint for the fade, and the group cross-fades the
refraction back once it has settled. Android, TV and desktop draw no glass
over video and are unchanged.

* Player on iOS 26+: the phone's own clear Liquid Glass under the controls

The player's controls stood on a Flutter shader that imitates iOS 26's
clear glass. On an iPhone whose OS has UIGlassEffect with the clear style,
each control now stands on the real material, hosted through the existing
NativeGlass bridge. It refracts the video and the page alike, and follows
Reduce Transparency and Increase Contrast as Apple maintains them. A layer
that goes native draws no shader group, because hosted glass inside a
refracting group would ruin it. Older iPhones keep the shader, not the
frosted system blur. Android, TV and desktop are unchanged.

Podfile.lock now includes the ML Kit pods added with page translation.

* Player on iOS 26+: the controls are the phone's own Liquid Glass buttons

One hosted UIKit view per group of controls (soplay/native_player_bar):
back, the top bar's actions, the cluster round play, the bottom row and
the social rail, each real UIButtons in a UIGlassContainerEffect, as clear
glass capsules or glass circles with play on prominent glass. Dart lays
every slot out (RTL, label widths, text scale) and UIKit places them;
hits land only on a slot, so the gaps stay the page's. The bars fade in
UIKit and leave the frame once faded; only what Flutter draws keeps the
Flutter fade. Speed, fit and sleep open a native menu on a hold, with
VoiceOver's long press still reaching the Flutter sheet.

Gated on a new glassControls capability, Reduce Transparency off, and an
iPhone; iOS below 26, Android, TV, desktop and the small players keep the
Flutter controls unchanged.

* Player native bars: review fixes

- Busy ring keeps spinning after the app or the bar leaves the window
- Unlock pill keeps its deeper 0.6 dimming on the native glass
- Fit button shows the current mode's glyph
- VoiceOver no longer reads a title that repeats the label
- Seek buttons say the real step (player.rewind_by / forward_by)

* Native player bars: system-sized glyphs, untinted glass, no alpha on glass

Glyphs are 17 pt medium on bar buttons (22 for skip, 28 for play, 13 in the
unlock pill), as the system's glass toolbar buttons are, instead of 20-32
semibold. The clear glass carries no dark wash unless a caller needs the
contrast, which only the unlock pill does. Separate circles stand 12 apart.
Show and hide no longer animate alpha on the hosted root: a visual effect
view, or anything above it, under alpha 1 draws its effect wrongly. The
capsules dematerialise by losing their effect inside the animation, and a
hidden bar is isHidden.

* Player on iOS 26+: Apple's symbols, pull-down menus, iOS sheets

- Symbols checked against the phone's own list: Watch Party wears
  person.2 (not SharePlay), unlock lock.open.fill, rotate the
  rectangle.*.rotate pair, fit AVKit's zoom arrows and the stretch
  arrows; filled faces for party and cast. A name UIKit lacks falls
  back to its stem instead of drawing nothing.
- Speed, fit, sleep, quality, server, sub/dub and subtitles open the
  phone's own menu on a tap: the current choice checked, a line under a
  row where the sheet had one, a red row for a file that will not play.
  The subtitle menu ends on "More options", the full sheet. VoiceOver's
  tap still opens the Flutter sheet.
- The player's sheets on that path: a grabber, 38pt continuous corners,
  a frosted material, a centred title with a close button, inset grouped
  rows with checks and chevrons, Cupertino glyphs. Everywhere else they
  are the sheets they were.

* Player on iPhone: choose the controls style

Settings > Player > Controls style, iPhone and iPad only:
- Native (iOS 26), the default: the phone's own Liquid Glass buttons,
  pull-down menus and iOS sheets.
- System glass: Flutter's buttons on the phone's own clear glass, the
  Flutter taps and sheets.
- Classic glass: Flutter's buttons on the shader glass, the Flutter
  sheets, as in 3.2.1.

Below iOS 26 the first two are disabled and the player keeps the classic
glass; Reduce Transparency still overrides all three. Stored in Hive as
player_controls_style and read when the player opens.

* Player on iPhone: classic controls by default, offer the native ones once

A viewer who never chose a style gets the 3.2.1 classic glass. On iOS 26
with Liquid Glass available, the player asks once, after playback starts
and the controls show, whether to try the native controls. Try it stores
native and swaps the controls in place; Not now or any dismissal stores
classic. The offer is marked shown before it appears.

* Detail tabs: keep panes alive and stop rebuilding the page on a switch

Every tab change ran setState on the whole detail page (header, action
row, both bars) and the AnimatedSwitcher threw the old pane away, so
Relations and Comments refetched and Similar rebuilt its grid on every
swipe. DetailTabPanes now listens to the TabController itself, builds a
pane on first visit and keeps it offstage afterwards, with the same
160 ms cross-fade.

* Detail tabs: decode Similar posters and cast avatars at drawn size

* Decrypt KissKH .txt1 subtitles on the device

KissKH's .srt.txt1 tracks are plain SRT with every cue text line
AES-128-CBC encrypted and base64 encoded. The extractor (v7) and
/contents/media tag them with a cipher object; the app ignored it and
showed the ciphertext.

The subtitle loader now decrypts such a track before parsing it, per
docs/SUBTITLE_CIPHER.md: cue numbers, timing and blank lines pass
through, a line that will not open is kept, and an unknown cipher or a
key that opens fewer than half the lines fails the track, as on TV.
Downloads keep the decrypted file, since the sidecar manifest has no
key. Ciphered tracks are not handed to Cast or an external player,
which would only show the ciphertext.

AES comes from package:cryptography (pure Dart), already in the lock
file as a transitive dependency.

* Read extractor answers as plain JSON on iOS and macOS

On Android, callAsyncJavaScript results arrive as JSON text and decode
to Map<String, dynamic> all the way down. On iOS and macOS WebKit's
objects cross the platform channel, so every nested object is a
Map<Object?, Object?>, and _coerceMap only cast the top level.

For KissKH, a client-mode provider, that meant on iPhone:
- home and view-all failed with "type '_Map<Object?, Object?>' is not
  a subtype of type 'Map<String, dynamic>'";
- a search result opened to the same error on the detail page, and
  the episode list too;
- resolveMedia's whereType<Map<String, dynamic>> silently dropped every
  video source and subtitle.

The answer is now converted to plain JSON types throughout. The test
replays live KissKH v7 payloads through StandardMessageCodec, the same
path the iOS channel takes.

* Return device extractor answers as JSON text

The extractor call now returns JSON.stringify of the provider's answer,
so iOS and macOS decode the same JSON text Android already gets. Before
this, WebKit's conversion sent every object as a Map<Object?, Object?>
and every number as a double. jsObjectFrom converted the maps, but a
view-all page still failed on iPhone with "type 'double' is not a
subtype of type 'int'" because its page number came back as 1.0.

jsObjectFrom still converts channel maps. It now also turns whole
doubles back into ints. New helpers jsonMap and jsonMaps do the same
deep conversion for maps read from a channel or a Hive box. jsonInt and
jsonText read numbers and text that a source sends in either form.

ExtractorRunner no longer hard-casts its decoded answer.

* Read Zangetsu metadata whatever shape it arrives in

The server's Zangetsu wrapper passes a script's cast, screenshots and
extra through unchanged. One 18+ source sends its screenshots as bare
image URLs. DetailModel cast each entry as Map<String, dynamic>, so its
detail page failed with "type 'String' is not a subtype of type
'Map<String, dynamic>' in type cast" on iOS and Android.

What changed:
- A screenshot may now be a URL or a {full, thumb} map.
- A cast entry may be a map, with its image under image, avatar or
  photo, or a bare name.
- A genre may be a {name} map.
- Year, likes and counts are read from numbers or numeric text.

Home, view-all, search, episodes and media now convert every nested map
through jsonMaps instead of casting it. A map from a channel or a Hive
box no longer throws, and media no longer drops sources that
whereType<Map<String, dynamic>> skipped. ViewAllPagingModel no longer
assigns page, totalPages and provider unchecked. HistoryItem reads its
numbers as num, so a double from a channel cannot fail the
continue-watching row.

The fixture holds an 18+ device source's live answers, with titles,
images and URLs replaced by placeholders, plus the server passthrough
shape. Each payload goes through StandardMessageCodec with doubles, as
WebKit delivers it, and also as JSON text.

* Player: Android PiP, audio focus, media session and brightness fixes

- Closing the PiP window pauses and clears PiP state (onPictureInPictureModeChanged
  forwarded over soplay/pip); PiP is marked before entry and rolled back on failure.
- Pause only on paused/hidden on mobile, not inactive.
- Audio focus and becoming-noisy for libmpv via AudioFocusHelper; the DRM
  ExoPlayer handles focus and noisy itself.
- Brightness handed back on exit and background, re-applied on resume;
  iOS saves and restores the system brightness.
- Swipes start from freshly read levels; window brightness no longer
  guesses from SCREEN_BRIGHTNESS/255.
- Auto-enter PiP on Home while playing (setting, default on).
- PiP actions: localized labels, the real seek step, vector icons.
- MediaSessionCompat while the player is open; Space and arrows on Android
  with a hardware keyboard.

* Fix player logic and UX across platforms

- Desktop: re-apply volume and mute on every new controller, persist them
  in Hive, allow up to 150% on mpv via volume-max.
- Retrying the same source shows the switch screen with the attempt and
  Stop (ServerSwitch.retrySame); TV focuses Stop.
- Error screen scrolls, has a compact landscape layout, always shows a
  translated headline with the raw detail under Details; Dio timeouts and
  HTTP 4xx/5xx are classified.
- End of stream: engine completion or a playhead stuck near the end
  advances once.
- Tap-tap seeking, double-tap centre play/pause, no tap lag when a double
  tap cannot follow.
- Lock pill fades after 3 s; BACK while locked shows it.
- Swipes from system gesture edges are ignored.
- AniSkip skips go through _seekTo; OK skips on TV; Up Next on TV.
- Native codec fault offers mpv for the session; mpv mid-play failure
  retries once with hwdec=no.
- mpv Auto starts from the connection type and steps down after repeated
  stalls, with Undo.
- Vertical clips turn the phone to portrait.
- Player notices are a top-centre glass toast; skip pill and error buttons
  use the player glass style.

* App-wide data, reliability and performance fixes

- Watch Later / Watched: persisted outbox of pending add/remove ops,
  coalesced per item and replayed oldest first before a fetch replaces the
  cache and on connectivity regain; unsent ops are laid over the server
  copy. A failed fetch with no cache shows an error with Retry.
- Alternate-source sheet: one shrink-wrapped CustomScrollView with
  SliverGrid/SliverList so only visible tiles build; posters decode at
  tile width x dpr (also in the relink sheet).
- Always-on blurs (classic nav bar, floating pill, home top bar, search,
  My List, profile and view-all headers) fall back to a translucent fill
  when GlassPowerReading.reducesEffects (Go/low RAM, GL ES 2, Battery
  Saver).
- HistoryService: decoded-row identity cache plus sorted-list memo,
  invalidated on writes and box events; count without decoding.
- View-all/actor grids: loadMoreFailed footer with Retry and exponential
  backoff for scroll-driven requests. Pull-to-refresh on History.
- Decode sizes for comment avatars, shorts images, aniyomi logo and local
  posters; remove the unused lib/features/my_list/my_list duplicate.
- Off Android: rotating local crash log under app support, shown in the
  log viewer and appended to support diagnostics; friendly release
  ErrorWidget with Go home.
- Tablets/foldables: no portrait lock at shortestSide >= 600, nav rail on
  expanded-width mobile, grids pass context to the responsive delegate.

* Open deep links over Home from one consumer, keeping the viewer's place

Flutter's own deep linking is off on Android and iOS, so DeeplinkService
is the only thing that handles a link. It opens the target over /main
with typed args, so back returns Home, and holds a link that lands
during splash, onboarding or the profile picker until they finish.

/play links carry ep and t through DetailArgs to the episode list and the
player's start position. The provider is passed per navigation instead of
being saved as the current source. Routes that need an extra redirect to
/main without one. The share link builder can emit ep and t for the player.

The manifest also opts in to OnBackInvokedCallback for predictive back.

The first download started without notification permission on Android
offers the priming sheet, with download-specific copy in all locales.

* Android polish: predictive back, themed icon, no background polling

The bloom pages follow the back gesture on Android, and the theme pins
the predictive back transition. The launcher icon gets a monochrome
layer. The unread-notifications poll stops while the app is in the
background and catches up on return.

* Make catalog chips and the settings side-nav focusable; mouse back pops

The chips and the desktop settings nav use HoverTap, so a remote or the keyboard can reach them. The mouse's back button dismisses the topmost route, as Escape does. Drops an unused import analyze flagged.

* Cantonese: write crash log with the house term 紀錄
@professorDeveloper
professorDeveloper deleted the fix/player-glass-hold branch November 10, 2026 07:33
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