Skip to content

feat(playback): lower Auto quality when playback keeps buffering, and test connection speed - #636

Open
Quick104 wants to merge 141 commits into
mainfrom
feat/buffering-quality
Open

Quick104 wants to merge 141 commits into
mainfrom
feat/buffering-quality

Conversation

@Quick104

@Quick104 Quick104 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Related issue: Silo-Server/silo-server#2699 (server side), Silo-Server/silo-server#637, #631
Validation tasks: changes #7 and #8 (Play and Playback robustness), #347 C3 and #348 C3 (manual quality), and #344 C1 and #345 C1 (own-device overrides); none of them is recorded as passed

When a remote connection can't keep up, Android playback stalls, recovers and stalls again until the viewer picks a lower quality by hand. Since #613, Auto requests carry no bandwidth measurement, so the server plans the source file whenever the device can play it, even on a link slower than the file. #631 is one report: a 50 Mbps file played directly over Wi-Fi that delivered about 30–40 Mbps, and it buffered four times in 90 seconds.

This brings the server's buffering-quality design to the phone and TV apps. They measure the connection and send that reading when playback starts. When playback keeps buffering anyway, they lower quality, or offer to.

Approach

The apps follow the buffering-quality spec as settled at silo-server 4ab2b8e95, after a review that compared web, Apple and Android against it.

  • Detecting buffering. One BufferingQualityController in shared/ drives both players, fed by one Media3 adapter. Its tests replay the spec's conformance vectors.
    • Only running out of data is a stall. A wait that begins with more than a second of media buffered is Media3 catching up to a keyframe. It counts only if the buffer drains while it lasts, which the adapter checks every 200 ms.
    • On a tunneled player every wait counts, because its buffered figure includes samples already in the codec.
    • A subtitle or cue refresh on the phone is a reload, not a stall. Until a plan first plays, though, a reload or seek is part of the start's wait, which counts from the first wait; a pause before then ends the current wait without ending the start.
    • An outage (failing media requests, or no network) pauses stall timing. It ends only when the plan's media data arrives again or playback runs; a subtitle or playlist load doesn't start or end one, and an adopted plan starts with none.
  • Lower quality when buffering is a new Playback setting (playback.buffering_quality_mode): Automatically (the default), Ask first, or Never.
    • Automatically lowers Auto at once and shows a Restore quality pill. Ask first shows a pill offering Lower quality. A quality the viewer picked in the player is always asked about. Never only reports.
    • Lower quality on a picked quality steps to the highest rung below the lower of the rung the plan names in active_quality and the delivered bitrate, or below the delivered bitrate when the plan names none.
    • A playback suppression, such as a transient audio-focus loss, counts as a stop, as a pause does.
    • Pills wait while an intro, credits or Next Up pill is up, while the TV player menu is open, in picture in picture, and on the phone while Silo is in the background. Notices stay on screen for 5 seconds of visible time.
    • A quality pick hides any pill as it is sent, and nothing prompts or lowers until its plan is adopted or the server refuses it.
    • The viewer's answer, the "Can't lower quality" notice and the wait before a new offer belong to the title. Retry or a renewal keeps them. A quality pick, a version pick or the phone's Wi-Fi/cellular quality switch clears them once its plan plays; one the server refuses doesn't.
    • A lowering that meets another replan in flight is refused and tried again later. A Restore quality that meets one waits for it, and its learned_limit: "clear" goes out once, on whichever request comes next.
    • In a Watch Party the controller times stalls and reports plans, but never lowers or prompts.
  • Learned limits. Lowering Auto is a quality_change replan whose estimate is 70% of the delivered bitrate.
    • The server keeps that as a limit for this device and connection for 7 days, and Auto plans under it.
    • The Quality menu labels Auto "Auto · limited after buffering". Playback settings shows "Limited to 4.2 Mbps after buffering on Oct 18" with Reset.
    • Restore quality clears the limit. A quality pick drops the lowered estimate the playback kept.
    • Cast requests always send learned_limit: "ignore" and no estimate, because this device's limits aren't the receiver's.
  • Connection speed test. The app times the server's bandwidth probe over one connection, in 500 ms windows.
    • On sign-in, a return to the foreground or a network change, it reads the reading the server already holds for this device and connection.
    • It runs an automatic quick test (at most 3 seconds and 100 MB) only when automatic tests are on and no current reading proves the link. Test speed in Settings runs a 10-second accurate test.
    • With the local route up, the test goes over the LAN with the saved server's credentials.
    • No test runs on a metered network, while media plays, or in the background. A VPN's own metered flag doesn't count.
    • A result is kept only once the server stores it. If the report fails, Settings says the test couldn't be saved.
    • A 429 holds that server's tests until its Retry-After, and "Try again in N seconds" goes away when the time is up.
    • A playback start sends the highest current reading as bandwidth_estimate_kbps with source probe. A reading goes only to the server and route it was measured on.
  • Connection group in Playback settings: the speed ("48 Mbps · measured 3 minutes ago on Wi-Fi", or "Faster than 108 Mbps" for a lower bound), Test speed, and the server's Auto budget.
    • Rates round down: one decimal under 10 Mbps, whole Mbps above, with no digit grouping.
    • The group reads the connection again after a network change, Reset and Test speed.
    • On a metered network a note replaces the speed.
  • Plan summaries. Each plan that stops playing reports a plan_summary route event:
    • its stalls, delivered bitrate, mode, last buffering action and the estimate of the request that made it;
    • throughput_kbps, counted from this plan's media requests that received data for at least a second, and left out when the network changed while the plan played.
    • Every stall's time counts in the totals, and only stalls of a second or more in the count.
    • A failed plan reports when the error shows.
    • When the phone leaves the foreground it saves the playing plan's summary so far. If the process dies, the next launch sends it, within 24 hours. Anything sent at a later launch, including a live send that failed, goes without throughput_kbps.
  • Older servers.
    • The request fields, learning and summaries need the server's buffering_quality_v1 and playback_plan_summary_v1. The speed test and the Connection group need connection_speed_v1.
    • The setting needs a server whose settings manifest has playback.buffering_quality_mode. Without it the row hides and the apps run Automatically.
    • Without them, requests carry none of the new fields, and lowering still works through the estimate alone.
  • docs/playback/01-media3-only-player-architecture.md §3.5–3.6 and docs/playback/sequenced-api-v2.md describe the behavior.

It is one pull request for the same reason as the server's: the speed test is how Auto gets its budget, and the learned limit and the reading feed that same budget. The server side is Silo-Server/silo-server#2699, and the Apple apps get the same behavior in Silo-Server/silo-apple#842.

Validation

  • Supply chain (./scripts/test-check-build-supply-chain.sh, ./scripts/check-build-supply-chain.sh): passed.
  • Unit tests (./gradlew testDebugUnitTest) at c9c3d65, after merging main: 6,290 tests in shared, android-shared, androidApp, androidTvApp and libass-bridge, 0 failures. They replay the spec's 66 controller cases and 13 speed-test cases, vendored from silo-server aadc7fdf8. ServerSetupPersistenceTest occasionally fails two cases on a Dispatchers.Main race in a full run; it passes alone and on rerun.
  • Lint (:android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease) at 99d9217 (unit tests and debug builds re-run at c9c3d65; CI covers the rest): no findings beyond the baselines.
  • Builds (:androidApp:bundleRelease :androidTvApp:bundleRelease, :androidApp:assembleDebug :androidTvApp:assembleDebug) at 99d9217 (unit tests and debug builds re-run at c9c3d65; CI covers the rest): passed.
  • Manual on an onn 4K Pro (Android 14), against an isolated dev server with the link shaped:
    • Neither a 4K HEVC Dolby Vision remux nor a 1080p H.264 file played tunneled.
    • At 8 Mbps the 1080p plan ran out of data with 268 ms buffered. The wait counted, and after 6 seconds Automatically lowered it to a 6,000 kbps plan.
    • Separately, the 4K remux hit Media3's stuck-player timeout 10 seconds after its first frame on both starts. Silo recovered, and the slow restart then lowered quality to the 1080p version. Whether main does the same wasn't checked.
  • Manual at 0b3a808 against f3146fb, on Android 16 phone and TV emulators against the same server, now at settings revision 23 (the commits after 0b3a808 have unit tests only):
    • Test speed until the probe budget answered 429. Once the wait ended, the branch cleared "Try again in N seconds"; f3146fb kept showing it.
    • The same 108,134 kbps reading gave an Auto budget of "86 Mbps", where f3146fb showed "87 Mbps".
    • With Ask first, the TV prompt hid under the player menu for about 8 seconds and came back with its time left: on screen 15.0 s in all. On f3146fb the timer ran on under the menu, and the prompt was on screen about 7.5 s.
  • Manual at f3146fb, on Android 16 phone, tablet, foldable (folded and open) and TV emulators against an isolated dev server running the server branch, with the link throttled to 4 Mbps for playback:
    • A fresh sign-in ran the quick test, which reported the server's target, 108,134 kbps, as a lower bound after 500 ms.
    • Test speed settled at 372 Mbps on the phone and 467 Mbps on the TV.
    • The start sent the reading as bandwidth_estimate_kbps with source probe.
    • With Automatically, buffering lowered Auto from a 17,398 to a 6,000 kbps plan with a 12,178 kbps observed_rebuffer estimate, and showed Restore quality on all four devices.
    • Settings then showed "Limited to 12 Mbps after buffering on Oct 19", and Reset cleared it.
    • With Ask first, the prompt showed on the phone and TV.
    • On main, the same title on the same link buffered with nothing offered.
  • Not tested:
    • physical devices other than the onn 4K Pro, and a TV that does play tunneled;
    • the speed test over a real local route;
    • a process death with a saved plan summary, Cast, the owed Restore clear, the outage end and the start wait through a reload, which have unit tests only;
    • moving between Wi-Fi and a real cellular or metered network.

Evidence

Evidence: https://evidence.siloserver.org/r/silo-android/buffering-quality/

Surface and build: the first set compares main at b1e5774 with f3146fb on Android 16 emulators (phone, tablet, foldable folded and open, and Android TV 1080p) against an isolated dev server running the server branch. Each pair shows the same screen, profile and title: Playback settings, and Aladdin buffering on Auto at 52:37–52:48 over a 4 Mbps link. The Ask first prompt, Test speed and the learned-limit row are new, so they have after captures only.

The review fixes compare f3146fb with 0b3a808 on Android 16 phone and TV emulators against the same server:

  • The 429 message on the phone, after its wait ended.
  • The Connection rows' budget for the same reading.
  • Recordings of the TV's Ask first prompt under the player menu, with the log timing. These runs use different titles, because the prompt's answer belongs to a title.
  • The onn 4K Pro tunneling check.

Tablet and foldable run the phone app's Settings code, so the 429 and rounding changes reach them unchanged; they weren't captured again. The menu rule is TV-only.

Risks

  • Automatic lowering is on by default for Auto. A viewer who finds it wrong can switch to Ask first or Never, reset a learned limit, or cap quality with Maximum bitrate.
  • Speed tests count against the server's probe budget of 2 GB per account per 10 minutes. A household whose devices run several Test speed checks in a row can get "Try again in N seconds".
  • Movies — Android #7 and Movies — Android TV #8 list in-session auto quality-stepping as out of scope for 1.0, and this adds it.
  • The vendored playback-v3 wire fixtures and settings manifest stay at silo-server 857655f4f, re-vendored for the new fields. Five v3 fixtures differ from the server's current copies because of unrelated server changes (bitrate policy, sync_key, Auto best-effort). Re-vendoring them is a separate change.

Checklist

  • I read and can explain the complete diff.
  • This pull request addresses one concern.
  • The Evidence section shows every change a user can see, or says there is none.

AI Disclosure

  • Harness: Claude Code, running in T3 Code
  • Tool(s): Claude Code subagents (code review, code search, implementation); GitHub CLI
  • Model(s): claude-opus-5-5
  • Involvement: Fully AI-generated. The maintainer set the product decisions; human review is pending.
  • Adversarial review:
    • Two Claude Code review agents read the first diff (code only; they ran no builds or devices). They found five bugs, all fixed with tests.
    • A later cross-platform review by Claude Code agents compared web, Apple and Android against the spec. The maintainer ruled on its open questions, and the spec now settles them. This update applies its Android findings, except the one left open below. Two subagents implemented parts of it in their own worktrees: the speed test and Settings fixes, and the saved plan summaries.
    • A Claude Code audit of this branch then simplified it (about 760 lines fewer, behavior kept) and found gaps against the spec, now fixed: a TV Restore dropped while busy, audio-focus suppression, TV Back on API 35 and below, the phone notice behind the AI-subtitle notice, network validation, Settings read ordering, the "Try again" timing, a rolled-back TV plan identity, and pill-timer drift.
    • The follow-ups the first review left open are fixed: a Restore during another replan, failing subtitle loads pausing stall timing, the mode before settings load, and a Settings read after a network change.
    • Left open: the 4K stuck-player timeout on the onn 4K Pro, and Settings' own connection read, which still doesn't feed the readings starts send.

🤖 Generated with Claude Code

Note

Add buffering-quality lowering and connection speed testing to playback

  • Adds a shared BufferingQualityController that detects network stalls during playback and either lowers Auto quality automatically or prompts the user (Lower/Keep), with an undo/Restore pill and a persisted never/ask/always mode setting.
  • Adds a bandwidth probe: timed downloads measure connection speed, results are reported to the server, and Settings shows the current reading with a Test speed action (skipped on metered/cellular networks).
  • Sends bandwidth estimates and learned-limit instructions on playback start/replan requests, feature-gated so older servers receive no new fields; playback throughput is metered via a Media3 TransferListener and plan summaries are journaled as route events with a 24-hour replay window.
  • Wires the pill UI into both the Android and Android TV players (focus handling, Back/Select keys, priority below existing notices and Next Up), and labels Auto as constrained when a learned limit applies.
  • Risk: new buffering-quality fields are gated by gatedForBufferingQuality against server features — check SequencedPlayback admission and replan paths; unsent route events now survive settled-journal compaction, so check PlaybackJournalEntry.keepingRouteEvents; settings sync now treats unconfirmed keys as local-only, which changes which keys are read from and flushed to older servers (AndroidPlayerSettingsStore.remoteReadKeys, DefaultServerSettingsFlusher.flush).

Macroscope summarized f509c13.

Quick104 and others added 30 commits October 17, 2026 19:12
Freeze the shared surface the phone and TV players will drive: the
playback.buffering_quality_mode enum, the controller's inputs and outputs,
and the buffering_quality_v1 request and plan fields from the spec's Wire
contract. Vendor the spec's conformance vectors unchanged. The controller
body follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er 857655f4f

Take settings manifest revision 21 with playback.buffering_quality_mode and
regenerate SettingKeys.kt with cmd/settingsgen, the 23-case buffering-quality
vectors, and the decision fixture that now advertises buffering_quality_v1 and
playback_plan_summary_v1.

The server's settings conformance fixture still targets revision 20, so
SettingsConformanceTest fails until the server bumps it; raised with the
server thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One state machine for the phone and TV players: the web player's stall
tracker (startup grace, counted and long stalls, outages re-timed), Auto
lowering with an undo in always mode, the prompt for ask mode and picked
qualities, the no-lower cooldown, rearm after an unadopted replan, and
per-plan plan_summary facts.

BufferingQualityVectorsTest replays all 23 vendored conformance cases on a
virtual clock and fails with every broken case named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… gate its requests

Add the Media3 adapter both players will use: it turns player and load
callbacks into the controller's playing, waiting (network, seek, reload),
pause, seek and transport-trouble events. It also tracks whether media data
is still arriving, so a startup-stall transport_stall on a slow plan that is
still receiving data belongs to the controller rather than the same-plan
reopen.

Requests carry buffering_quality_v1, bandwidth_estimate_source and
learned_limit only when the server advertises the feature, either in the
/api/v2 capabilities or in a decision's server_features; never mode sends
learned_limit: ignore. Controller replans go out as quality_change with the
step-down estimate. Each start and replan logs a one-line summary of its
body, and plan_summary route events go out when the server advertises
playback_plan_summary_v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t row

playback.buffering_quality_mode (Never, Ask first, Automatically; default
Automatically) joins phone and TV Playback settings next to Maximum Bitrate,
layered per profile and device like Skip Intros. It is newer than some
supported servers, so reads and writes are gated on contract revision 21 the
way the cellular quality pair is.

When the server advertises buffering_quality_v1 and reports a learned limit
for this device's connection, Settings shows "Limited to 44 Mbps after
buffering on Oct 17" with Reset, backed by GET /api/v2/playback/connection
and DELETE .../learned-limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone player now runs the buffering-quality controller outside Watch
Party rooms and downloaded files. A new plan is handed to the controller when
it mounts; the controller's replans go out as quality_change with the
step-down estimate, and a replan that cannot be sent at once is reported as
not adopted rather than queued without its fields.

The notice overlay gains the prompt (Playback keeps buffering: Lower quality,
Keep quality) and the undo (Quality lowered to stop buffering: Restore
quality) with the intro pill's countdown fill; Back answers them. The Quality
menu labels Auto "Auto · limited after buffering" while a learned limit holds
it down.

A startup-stall transport_stall on a plan that is still receiving data now
belongs to the controller: no same-plan reopen, no failure replan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec now treats each adoption as a new plan, since a server plan_id can
repeat; the controller keys its state on a local adoption identity and keeps
the server plan_id for plan_summary. The step-down estimate is the exact
integer floor of delivered x 7 / 10 (22120 gives 15484, not 15483). A
higher-priority pill now hides a visible buffering pill with its timer
frozen, and the pill returns with the remainder when that pill goes.

Re-vendor the 32-case vectors and the settings conformance fixture, which now
targets manifest revision 21, from silo-server bdd8892a0. On the phone, every
applied mount is a new adoption, and the recap and credits skip pill joins
intro skip and Next Up ahead of buffering pills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route events arrive on the session manager's IO scope while the test polls
them; a synchronized list does not guard iteration, so the poll could throw
ConcurrentModificationException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TV player runs the buffering-quality controller outside Watch Party
rooms. Each mounted plan is a new adoption except a client-side same-plan
remount, which is a reload. The controller's replans ride the quality
transaction so subtitle state survives them, as a menu pick does, and a
replan that fails or is dropped before commit is reported back.

The notice overlay gains the prompt and undo pills with the intro pill's
countdown fill and focus rules: the pill takes focus unless the viewer is
mid-scrub, is not drawn while the HUD or Next Up is open, Left/Right stay
inside the prompt, and Select and Back are handled at the player root.
Intro, recap/credits and Next Up pills outrank it. The HUD labels Auto
"Auto · limited after buffering" under a learned limit, and a slow plan that
is still receiving data no longer takes the startup stall's same-plan reopen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the buffering controller lowered Auto, the next automatic replan in the
same playback (a subtitle track change, a seek reanchor or a failure
recovery) went out with no estimate, so a server without
buffering_quality_v1 planned the source again and playback stalled, lowered
and climbed back in a loop.

Every later replan in the attempt that reads as Auto now carries the
step-down estimate, without bandwidth_estimate_source since it is not a new
observation. Restore quality, any quality change the controller did not
send, and a new attempt drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a throttled link the startup stall detector reported decoder_no_output
with 1.5 s buffered: the decoder had a little input and no first frame
because the network had not delivered more, not because it was wedged. The
failure replan that followed raced the buffering controller's lowering.

While the player is buffering with less than the threshold buffered, the
transport now owns the stall, as it already does for the Dolby Vision
transform deadline: the decoder deadline stays anchored, and a stall that
expires is a transport_stall, which the screens hand to the buffering
controller when data is still arriving. A playing player with silent video
is still the decoder's fault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the web player's probe to Android: a 40 MB download timed over a
2-second window from the first byte, with the same warm-up, first-byte
deadline and minimum-window rules. It runs in the background a few seconds
after sign-in or a return to the foreground, never while media loads or
plays, and never on a metered or cellular link, against servers that
advertise connection_speed_v1 and a bandwidth_probe_url. A redirect to the
media proxy is followed without credentials. Results are cached for 30
minutes, discarded on any network change, and reported to the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A video start now sends the probe's fresh estimate as
bandwidth_estimate_kbps with source probe, so Auto can rule out a source the
link clearly cannot carry. Only starts carry it: a probe estimate is a hint,
and replans never raise quality from it. Servers without
buffering_quality_v1 get the estimate without the source. Media3's
bandwidth meter is still never sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mance

The client now reads and sends bandwidth_estimate_kbps on start and replan,
so the golden fixtures round-trip without the UNSENT_NETWORK_EVIDENCE
exemption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phone and TV Playback settings gain a Connection group on servers that
advertise connection_speed_v1: the measured speed with when and on what
network it was measured, a Test speed action (a 429 says when to try again),
and the server's own "Auto plays videos up to N Mbps without converting
them" budget. On cellular or metered links the row says speed tests are
skipped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r architecture

Update the quality policy and network inputs sections for the buffering
controller and its server-side learned limit, the probe and its gates, the
probe estimate on starts, the step-down estimate on lowering replans, and
the rule that Media3's bandwidth meter is never sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule for repeating a lowering's step-down estimate on later Auto
replans lived inside PlaybackSessionManager, out of reach of the shared
vector runner. The revised buffering-quality vectors now test it through
client_replan, quality_changed and new_attempt events, so the rule moves to
a pure function in shared/domain/player that the manager calls under its
existing atomic reference. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring vectors

Re-vendor vectors.json unchanged at silo-server 408328dc7, which adds the
kept-estimate rule and three events: client_replan, quality_changed and
new_attempt. The runner now passes the controller's replans and the
client's own replans through withKeptStepDownEstimate, as
PlaybackSessionManager does, and takes each replan body from the wire
request. All 37 cases pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The controller keeps no log, so a device log showed when a lowering was
sent but not the waits and resumes that led to it. The Media3 adapter now
logs every event it sends (playing, waiting with its cause, pause, seek,
transport errors) under the BufferingQuality tag, which is enough to replay
a field report's decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The player draws no pills in picture in picture, but the buffering pill's
15-second timer kept running there: the undo expired unseen, and an Ask
first prompt timed out and cleared the stall history. The buffering spec
now says a player that cannot show its pills hides them as it does behind
a higher-priority pill (silo-server 457592aac).

Picture in picture now counts as a higher-priority pill on phone and TV, so
the buffering pill waits with its remaining timer and shows when the full
player returns. The phone also counts a stopped activity, since playback can
continue in the background there; the TV player already pauses or closes
when it leaves the foreground.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the decoder falls behind, Media3 drops to the next keyframe and
reports STATE_BUFFERING with seconds of media still buffered. The adapter
read every such wait as the network running out, so a device that could
not decode fast enough lowered quality and taught the server a network
limit it never hit. On an overloaded emulator, a 2000 kbps plan through a
6 Mbit/s relay was lowered to 1120 kbps after waits that began with 3.6 s
and 6.4 s buffered. That matches the 2000 and 1120 kbps step-downs seen
earlier on the Mac mini.

A wait that starts with more than a second buffered past the playhead is
now a renderer wait, which the controller does not count, as with a seek.
If its buffer drains before playback resumes, it becomes a network wait.
Both players give the adapter the player's totalBufferedDuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring vectors

The spec now defines the renderer wait Android started reporting in
2e02cf9 (silo-server 416f0d8ed): a wait that begins with more than
RENDERER_WAIT_BUFFER_MS = 1000 of media buffered is not a stall, and one
whose buffer falls to that threshold becomes a stall from that moment.

Re-vendor vectors.json unchanged at 416f0d8ed (39 cases, with
renderer_waits_do_not_count and renderer_wait_drains_into_stall), map the
renderer cause in the runner, and move the adapter's threshold into the
controller's constants under the spec's name, so the constants check covers
it. All 39 cases pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings rounded every rate to whole megabits, so a 1400 kbps learned limit
read "Limited to 1 Mbps after buffering" and a 1013 kbps budget read "up to
1 Mbps". Below 10 Mbps that hides up to 40% of the figure.

The learned-limit row, the connection speed line and the Auto budget now
show one decimal below 10 Mbps ("1.4 Mbps") and whole megabits from there
("44 Mbps", as in the spec), formatted for the device's locale, with kbps
only below 100 kbps. The web already shows the decimal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reset re-read the connection but updated only the learned-limit row. The
Connection group's "Auto plays videos up to" line, which the server caps at
80% of the limit, kept the old figure until Settings was reopened: after
resetting a 1400 kbps limit it still read 1.1 Mbps.

A successful reset now reads the connection once more and updates both the
limit row and the Connection group, on phone and TV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Media3 adapter reports STATE_IDLE as a stop, and the controller treated
every stop as the viewer pausing. After a player error and its recovery,
the controller still counted the viewer as paused, so an undo pill shown
while the recovered plan buffered kept its 15-second timer frozen until the
first frame. On the emulator that left the pill up about 25 seconds too
long. The spec freezes a pill only while the viewer has paused.

A wait for any cause but a pause now means the player is trying to play:
the controller stops counting the viewer as paused and resumes a frozen
pill timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opens

Settings read the learned limit and connection speed once, when its
ViewModel was created. The TV ViewModel lives as long as the activity, so a
limit learned during playback never appeared in TV Settings until the app
restarted. The phone page went stale the same way when it sat in the back
stack under the player.

Both now read the connection whenever the Playback settings screen enters
composition, which covers returning from the player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describe the renderer wait from the spec (a wait that begins with media
buffered is not a stall) and the pill holds behind higher-priority pills,
in picture in picture and, on the phone, in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clients re-tested every 30 minutes while open, and on every network change.
For 300 users on 2 devices that is about 3,600 tests and 90 GB a day, each
saturating the server's upload for 2 seconds. The approved redesign tests
only when the server holds no current reading for this device and network.

The 30-minute expiry, its re-test timer and the 5-minute retry are gone. A
reading now lasts 7 days, for the server and network it was taken on, and
playback starts send it for that whole time. A check, a few seconds after
sign-in, a return to the foreground or a network change, uses the reading in
hand when it has one. Otherwise it reads this device's stored reading from
GET /api/v2/playback/connection and measures only when the server has none
young enough. A check that fails or is refused waits for the next one, and
after a 429 none measures before its Retry-After. Test speed always
measures, replacing a running automatic check. The metered, cellular and
playback gates are unchanged.

The target, lower bounds, playback samples and the new measurement wait for
the spec change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reloadMarked = false
lastBufferedPositionMs = null
lastDataAtMs = null
setTrouble(load = false, offline = false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

Premature outage clearing in reset() clears transport trouble during TV same-plan retries because TvPlayerViewModel.adoptBufferingQualityPlan calls reset() followed by markReload() without adopting a new plan, emitting onTransportErrorEnd before media arrives and resuming startup stall timing during the existing outage. Distinguish new-plan adoption from same-plan remounts and preserve transport trouble, including the device's offline state, for same-plan remounts.

Prompt for LLM

File android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/BufferingQualityPlayerAdapter.kt:

Line 246:

Premature outage clearing in reset() clears transport trouble during TV same-plan retries because TvPlayerViewModel.adoptBufferingQualityPlan calls reset() followed by markReload() without adopting a new plan, emitting onTransportErrorEnd before media arrives and resuming startup stall timing during the existing outage. Distinguish new-plan adoption from same-plan remounts and preserve transport trouble, including the device's offline state, for same-plan remounts.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Quick104 and others added 16 commits October 20, 2026 21:07
… phone and TV

The phone and TV carried identical label code and 36 identical strings
for the buffering-quality and Connection rows. They now live once in
android-shared: PlaybackSettingsLabels.kt and android-shared's first
values/strings.xml, which also holds the phone-only cellular note. Each
app still lays out its own rows; the screens that keep app strings
reach the shared ones as CommonR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A VPN keeps its Android network id when the network it runs over
changes, so a direct move between two Wi-Fi networks under one VPN is
not a new key. Following the network underneath needs API 31, and
behind a full tunnel the server sees one connection anyway, so the gap
is documented rather than closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media3 keeps playWhenReady and READY when a transient audio-focus loss
suppresses playback, so the adapter kept the player running or stalled.
Pill timers and played_ms ran on while nothing played, and a suppression
during a stall kept the stall timed after the buffer filled, which could
lower quality and teach the server a limit. A suppression is now a stop,
as a pause is, seeded when the adapter attaches. (Audit C2.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server files a plan summary's throughput_kbps under the connection
of the request that delivers it, so a plan that played across Wi-Fi and
cellular could credit one network with the other's rate. The meter now
notes the default network at each transfer and at each count, and a
count that saw it change reports nothing (spec D1, Playback samples).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec's lower rung (silo-server be5b70497) is the highest entry below
the lower of the rung the plan names in active_quality and the delivered
bitrate, or below the delivered bitrate when the plan names no entry. The
picked label no longer sets it, so a capped plan steps from what it
plays. The plan carries active_quality only from a server advertising
plan_active_quality_v1.

The vectors are re-vendored from be5b70497: 61 controller cases, two of
them new, all passing, with the harness reading active_quality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sked for

The pill timer subtracted the 1 s step it scheduled, not the time that
passed, so a late resume kept a pill on screen a little past 15 s, and a
freeze before an overdue tick lost that tick's elapsed time. Both now
subtract the time that passed, capped at what was left. (Audit C9.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quality pick, a version switch and the phone's move to a network's
quality pair cleared the title's buffering answer and re-offer wait as
soon as they were requested, so a pick the server refused still counted
(spec D16: a change the player refuses is not one). Each screen now
keeps what the last adopted plan played at (quality, cap, file), and an
adoption the controller did not ask for that differs from it is the
viewer's quality change. The pick still cancels a waiting Restore, whose
owed clear rides on the pick's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e controls

On TVs below API 36, Back reaches the player's key bridge. The buffering
pill and the intro pill each take the DOWN, but once the hidden pill has
recomposed, the UP falls through to the transport's Back and hides the
controls, so one press does two things and a second Back exits playback.

Latch the press a pill answered by its downTime, which its DOWN, repeats
and UP share, and swallow the rest of that press. The next press has a new
downTime, so the latch cannot stick if an UP is lost. The API 36
BackHandler path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… failure notice

The phone overlay shows a session notice first, then an AI subtitle
failure notice, then the buffering notice. The buffering notice's slot
waited only for session notices and a hidden player, so it spent its
5 seconds behind an AI failure notice, which lasts 8, and was never seen.

The overlay now reports whether it holds an AI failure notice, and the
slot waits while it does. Disposing the overlay clears the report, so a
notice left up then cannot block the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the earlier check

A check that starts on a connected network Android has not validated yet
can still be waiting on its connection read when validation arrives. The
recheck then joined that same run, so if the read failed, nothing read
again until the next network change or foreground return.

On validation, cancel an automatic run that has not started its download,
so the recheck reads afresh. Test speed, a running download and a
result's POST carry on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three reads write the Connection group's server state with nothing
ordering them: the page's read, Reset's re-read and Test speed's re-read.
A slow page read that answered after Reset could bring back the learned
limit the viewer had just reset, until the next read.

Number each read and drop an answer once a read that started later has
been shown. A newer read that was cancelled never counts as shown, so it
cannot hide an older good answer. Test speed's re-read still keeps the
rows when it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test speed's "Try again in N seconds" started its N-second timer only
after the connection read that follows the test, so a slow read kept the
message up past the server's Retry-After. The spec says it shows until
N seconds have passed.

Mark the wait's end when the 429 arrives and hide the message then. A
wait that ran out during the read shows no message at all. The message
still says the N the server sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he controller adopted it

The session manager remembers a plan's identity when a replan commits, for
the plan summary it later sends. A TV replan commits before it publishes,
so a replacement rolled back before it mounts left its identity queued
under its plan id. Plan ids repeat within a playback attempt, so a later
adoption of the same plan sent its summary under the rolled-back plan's
session, attempt ids and estimate.

Add PlaybackSessionManager.forgetPlanIdentity. The TV calls it when a
reset or a lost context abandons a commit before adoption, and on a
rollback whose mount the buffering-quality controller never adopted. A
compensating rollback after the controller adopted the replacement keeps
the identity, since the predecessor's remount sends that summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… unknown

The spec says a link whose type the platform cannot report counts as
unmetered for the connection read, the start estimate and Test speed,
but a phone or tablet runs no automatic test on it. Android names the
link in almost every case. The exception is a VPN that runs over no
Wi-Fi, Ethernet or cellular network it names: its own metered flag does
not count, so nothing says whether it rides mobile data. The phone ran
automatic tests there.

Treat that link as unknown. The phone app starts the scheduler as a
phone or tablet, and its automatic checks still read the server's
readings there but end without a test. Test speed, starts, the main
quality pair and the Settings row behave as on any unmetered link. The
TV is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wnload

A playback start cancelled a speed test only once it was downloading. A
Test speed still reading the capability response went on to download if
the player had not begun loading by then, and ran beside the start until
the player's loading cancelled it. The spec stops Test speed when
playback starts before its test begins.

A start now also cancels a Test speed that has not reached its report.
An automatic check still finishes its connection read and then holds
off, as before, and a result already being reported still goes out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

…uiet while it is pending

Two points of the controller's new event-table row for a menu pick
(silo-server 243fb595c, D12 refinements):

- Any pill hides as the pick is sent. Since bfce490 the controller
  heard of a pick only when its plan was adopted, so a prompt or undo
  pill stayed up while the pick was in flight.
- A controller lowering adopted while a pick is pending shows no undo
  pill and no notice; the pick replaces that plan.

The pick is pending from when it is sent until its request is over:
its plan is on the way or it was refused. The answer, stall history
and kept estimate still change only when the pick's plan is adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

Comment on lines +513 to +514
pickPending = true
hidePill()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

Duplicate quality prompts occur because onQualityPickSent hides the existing pill, but evaluate can recreate it while pickPending is true: when an ASK-mode long stall recovers before the quality request finishes, onPlaying records longStallEndedAt and immediately calls evaluate. Gate trigger evaluation while the viewer's pick is pending, then reevaluate the current stall when the request settles without a replacement plan.

Prompt for LLM

File shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/BufferingQualityController.kt:

Line 513 to 514:

Duplicate quality prompts occur because onQualityPickSent hides the existing pill, but evaluate can recreate it while pickPending is true: when an ASK-mode long stall recovers before the quality request finishes, onPlaying records longStallEndedAt and immediately calls evaluate. Gate trigger evaluation while the viewer's pick is pending, then reevaluate the current stall when the request settles without a replacement plan.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

… once when it is refused

The D12 vectors (silo-server aadc7fdf8) pin the controller's menu-pick
row. Two points 668e88b missed:

- Nothing prompts or lowers while a pick is pending, so a pill the pick
  hid during a long stall no longer comes straight back.
- A refused pick changes nothing but the hidden pill, and a long stall
  in progress is evaluated at once.

The pick stays pending until it is refused (the phone's failed quality
change, a failed TV transaction), until a plan the controller did not
ask for is adopted, or until the title resets.

The vectors are re-vendored from aadc7fdf8: 66 controller cases, five
new, all passing. The harness maps pick_sent and pick_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

)
}
noteQualityCommitted(snapshot.transition.committed.qualityPreference)
if (snapshot.qualityChangeFailed) bufferingQualityController.onQualityPickRefused()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Unsettled pickPending after a terminal quality request failure causes the new evaluate guard to disable buffering prompts and automatic lowering until another plan adoption or reset: activePlaybackQualityId can highlight a different delivered rung, allowing selection of a preference that already equals the committed preference, but MobileSubtitleTransactionAdapter.changesQuality then makes qualityChangeFailed false. Settle pending picks on terminal request failure regardless of whether the requested preference differs from the committed value.

Prompt for LLM

File androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt:

Line 4393:

Unsettled pickPending after a terminal quality request failure causes the new evaluate guard to disable buffering prompts and automatic lowering until another plan adoption or reset: activePlaybackQualityId can highlight a different delivered rung, allowing selection of a preference that already equals the committed preference, but MobileSubtitleTransactionAdapter.changesQuality then makes qualityChangeFailed false. Settle pending picks on terminal request failure regardless of whether the requested preference differs from the committed value.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

…icks

The pick rule covers a version switch and the phone's move to the
quality pair of the network it is now on (D12/D16 scope of a "pick"),
not only menu picks. They counted as quality changes only at adoption
(bfce490), but did not hide the pill or hold the controller while in
flight.

Now each one calls onQualityPickSent() when issued. A failure calls
onQualityPickRefused(): the phone's failed version switch before it
restarts the previous version, a TV version switch whose replacement
load fails while the current version keeps playing, and a network move
after which the session's quality pair is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

A pick can be queued behind a replan and replaced by a newer one. The
controller's pending pick was one flag, so the older pick's refusal, or
the adoption of its plan while the newer one waited, ended it, and the
controller could prompt or lower while the newer pick was still on its
way. On the phone, a network move checked its outcome only after the
menu picks it held back had been sent, so it could do this too.

Each screen now keeps its latest pick in BufferingQualityPicks: how to
recognize that pick's plan, and whether its request is still on the
way. Only the latest pick's refusal settles the pending pick. An adopted
plan settles it unless it is another plan while the latest pick is still
outstanding, which onPlanAdopted's new pickStillPending carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

…doption

When a subtitle change couldn't be adopted, the phone stopped the
session and then reloaded the title, and the undo pill stayed up while
the session stopped. A Restore tapped then replanned the plan being
ended, so its learned_limit clear could be refused or lost. Every other
same-title restart ends the plan as it begins (onNewAttempt), which
hides the pill; this one now does too, before the stop. A Restore that
was already waiting gives its owed clear to the restart's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silo-kody

silo-kody Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request evidence-required Needs before-and-after evidence before the Evidence check passes impact: playback Can't play, stalls, or wrong media priority: P2 Limited scope, workaround exists, or polish size/XXXL 3000+ added + deleted lines (all files); change volume, not priority.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant