Skip to content

Sozo 3.2.1: Telegram-style desktop corner player, no endless loading, downloads and reconnect fixes - #46

Open
professorDeveloper wants to merge 34 commits into
masterfrom
release/3.2.1
Open

professorDeveloper wants to merge 34 commits into
masterfrom
release/3.2.1

Conversation

@professorDeveloper

@professorDeveloper professorDeveloper commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Sozo 3.2.1

Fixes for every real problem reported in support since 3.2.0, plus the Telegram-style desktop corner player.

Desktop: corner player (Telegram-style PiP)

  • On macOS/Windows/Linux, the PiP button (or P) docks the running player into a small floating window in the corner of the app. You can drag it and resize it, and it stays on screen while you move between pages (home, search, details, settings).
  • Clicking the window brings back full-screen playback. Closing it stops playback and saves history.
  • The live player state moves over without reloading the video. Continue Watching keeps updating while the player is docked.
  • The old whole-window mini player is still there as a secondary pop-out (Shift+click / long-press on the PiP button).
  • Mobile and TV paths are unchanged: everything is behind isDesktopPlatform, and Android native PiP works as before.

Player: no endless "Video loading", recovers from dropped connections

  • Each server gets a start deadline. If the video does not start in time, the player moves to the next server automatically with a short notice. When every server fails, it shows a clear localized message.
  • mpv reconnect options are enabled. After a network drop ("Connection reset by peer") playback resumes from the same position.
  • ExoPlayer initialize() is bounded and can no longer hang forever.
  • Quality pinning on ExoPlayer: picking 1080p really locks to the 1080p variant, and Auto shows plain "Auto" on the platform player.
  • media_kit redraw fix after resume.

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

  • Some hosts (vidapi, anikoto) serve real MPEG-TS segments as text/html. Both the Kotlin foreground service and the Dart transfer engine now sniff the first 512 bytes and refuse only real markup.
  • A tap-time probe gives a three-way verdict: OK / web page / unknown. Clear message DownloadBuildFailure.webPage.
  • Downloads now use the upstream URL instead of the local proxy URL. Sibling qualities keep their extractor.

CloudStream

  • PluginHost falls back to paged search for plugins that only implement it (MovieBox threw NotImplementedError).

Fixes from a pre-PR review

  • Downloads: when the default mirror serves a web page, the episode now fails only if every mirror does. Otherwise the bad mirrors are dropped from the choice. The film page shows the right message, and a relayed stream offers its upstream URL for download.
  • Player: a file cut short by the network reports the error instead of stopping silently. A live stream reconnects at once after a drop. Torrents from torrent search get the 90 s torrent start budget.
  • Corner player: restoring, closing or opening another title during the exit animation no longer puts the same page in two places. Signing out and switching profile close the corner player before account data is wiped.

CI

  • Preview cache prunes run one at a time. This fixes the flaky preview_grid_test that turned master red.

Server-side (already deployed in sozo-backend, listed for context)

  • asilmedia: new file host fayllar.com is signed.
  • TMDB merged/deleted ids follow the redirect.
  • primesrc v3/v4: WebView directive and embed-slash fix.
  • anikage: new servers.
  • Media-segment relay.
  • New provider: Sports Live TV (iptv-org sports channels, only working ones listed, Futbol TV UZ and Setanta Eurasia first). Shipped without a push announcement.
  • rareanimes: back. The site moved posts from /hindi/<slug>/ to /<slug>/.
  • multimovies: back. New domain (m1.multimovies.courses) and new site theme; the iqsmartgames video chain behind it is unchanged.
  • Support: "Could not open codec" on mpv (an AV1 file on hentaiocean, a downloaded uhdmovies episode) is covered by this release's automatic switch to the platform player on Android.

Support tickets covered

Report Fix
Downloads fail with "The source sent a web page instead of the video" (several tickets) Downloads sniffing
"Video loading" forever / request to switch servers automatically Start deadline + auto-switch
"Connection reset by peer" mid-playback Reconnect + resume at position
1080p looks like 480p ExoPlayer quality pinning
MovieBox search error CloudStream paged search
primesrc "Failed to recognize file format" Server v3/v4
TMDB 404 page Server
anikage / asilmedia not playing Server

Tests

  • flutter analyze: clean
  • flutter test: 2387/2387 passed
  • Kotlin unit tests: download tests (13) and PluginSearchTest (6) pass
  • New tests:
    • Flutter: web_page_sniff, transfer_engine, download_media_probe, download_request_builder, download_failure, download_choices_proxy, player_dock_geometry, player_dock_host, player_dock_source, plus the player readiness/retry tests
    • Kotlin: WebPageSniffTest

Notes for release

  • After merging, release with bump: patch (→ 3.2.1), then set the in-app updater in admin → AppVersions.
  • Downloads that already failed keep their old URL. Users should delete them and download again on 3.2.1.

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.
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.
'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.
- 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.
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).
… and the corner player; Continue Watching keeps up while docked
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.
…t 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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
Asked for in support. The pick is remembered on the device. Also drops an
unneeded import from the glass test.
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.
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.
… 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.
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.
…wering 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.
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.
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.
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.
Past the first slice the track stayed with the rest of the file still in the
source language, and no message.
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.
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.
…ect 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.
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.
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