Skip to content

Sync upstream Silo (silo-android) into Prairie Android - #37

Closed
JonahMMay wants to merge 109 commits into
mainfrom
sync/upstream-2026-10-11
Closed

JonahMMay wants to merge 109 commits into
mainfrom
sync/upstream-2026-10-11

Conversation

@JonahMMay

Copy link
Copy Markdown

Real merge of Silo-Server/silo-android upstream (80 commits) preserving upstream ancestry per docs/upstream-sync.md. Conflicts resolved with the established rebranding mapping; upstream additions placed at Prairie paths; upstream deletions honored; Silo Media L.L.C. attribution preserved. Merging only after CI (including Prairie invariants) is green.

Joloxx9 and others added 30 commits September 29, 2026 21:02
…r option (Silo-Server#413)

* feat(playback): add independent subtitle text opacity and a gray color option

White subtitle text against a dark background is uncomfortably bright on an
OLED or HDR display. The subtitle appearance settings had no way to dim the
text itself: backgroundOpacity only affects the box behind it, and the font
color palette had no gray option.

Adds a textOpacity field (1-100, default 100) alongside the existing
backgroundOpacity, applied to the text color independently of the background,
plus a gray swatch in the font color palette, on both phone and TV. Both
opacity controls moved from a slider to a typed percent field (phone) or a
D-pad picker (TV, matching the existing paradigm) after a slider proved too
imprecise to hit low values.

Also re-vendors the settings contract at manifest revision 13 and fixes a
found bug where the percent field committed on every keystroke instead of on
blur, which could leave the displayed value out of sync with what was saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(playback): keep TV opacity pickers in sync with off-cadence values

The TV background/text opacity pickers only listed their fixed step values
(0/25/50/75/100 or every 5), so a value set via the phone's free-typed
percent field could have no matching option. Opening the picker then
selected the first step, and pressing Select silently overwrote the real
value. Fixed with a shared helper that unions the step list with the
current value, matching the existing delayPicker pattern.

Also commits a pending opacity edit when the subtitle sheet is dismissed
while the field is still focused, since that tears the composable down
without firing onFocusChanged(false).

Found by CodeRabbit and Kody review on PR Silo-Server#413.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(playback): apply subtitle-appearance edits as a transform, not a snapshot

Every control in the phone subtitle-style sheet built its update as
appearance.copy(field = value) against a composable-captured appearance
snapshot, then wrote the whole object back. Two edits that both go through
that snapshot in the same composition pass — most reachably, the two opacity
fields committing independently as the sheet is dismissed, since the sheet
can tear down both PercentInputRows before either write round-trips back
into the read appearance would race: whichever write lands first can be
clobbered by the second, which was still building its new value from the
appearance the sheet had before the first write applied.

SubtitleStyleSheet's onUpdate now takes a transform, not a precomputed
value, and PlayerViewModel/SettingsViewModel apply it against the freshest
value read from the store immediately before writing — the same pattern
TvSettingsViewModel.editAppearance already used for the identical reason.

Found by Kody review on PR Silo-Server#413, after the previous commit's fix for lost
edits on sheet dismissal made this reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(playback): make subtitle-appearance updates atomic at the store

Reading subtitleAppearanceFlow.first() and then writing the transformed
result back separately narrowed the race the previous commit targeted, but
didn't close it: two concurrent callers (e.g. both opacity fields committing
as the sheet is dismissed, each in its own launched coroutine) could still
both read the same value before either write landed, and the second write
would discard the first edit.

Adds PlayerSettingsStore.updateSubtitleAppearance(transform), which reads
the current appearance from DataStore's own preferences snapshot inside the
same edit {} transaction that writes the result. DataStore serializes edit
calls against each other, so this is an actual fix rather than a smaller
window: no other write can land between the read and the write. The
interface keeps a default (read-then-write) implementation for test fakes
that only need to capture a value, not real atomicity.

PlayerViewModel, SettingsViewModel, and TvSettingsViewModel.editAppearance
(which had the identical read-then-write pattern, predating this feature)
all move to the atomic method.

Found by Kody (critical) and CodeRabbit (major) review on PR Silo-Server#413, after the
previous commit's snapshot-based fix made the race reachable but not closed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(settings): re-vendor settings contract at manifest revision 14

The server moved subtitle textOpacity to revision 14 because revision 13
went to the advisory_age overlay id. Re-vendor manifest.json and
conformance.json from the server and regenerate SettingKeys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(playback): gate subtitle text opacity on settings revision 14

The server schema for playback.subtitle_appearance only accepts textOpacity
from manifest revision 14 and rejects unknown properties. A server below 14
answers a write that carries it with a contract error, the flusher drops the
op, and the next refresh reverts every subtitle edit in that write.

The flusher now decides at send time: it strips textOpacity for a server
known to be below 14, sends it whole from 14, and holds the write (kept
queued and retried) while the revision is unknown, so a value queued before
the probe answers neither fails on an old server nor resets the stored value
on a new one. The revision comes from a shared SettingsContractRevision cache
that refreshFromServer re-reads before it pushes.

The phone style sheet, TV HUD, and TV settings hide the Text Opacity control
when the server is known to be below 14.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(playback): enqueue subtitle appearance writes in commit order

updateSubtitleAppearance applied its transform inside the DataStore edit
but enqueued the result after the edit returned. The flusher keeps the last
value enqueued per key, so two concurrent edits that committed A then B
could enqueue B then A, and the server kept the older composite.

The enqueue now runs inside the transaction, in updateSubtitleAppearance,
flushProjectedSubtitleAppearance, and the override-enable path, so the
queued value is always the one the latest commit wrote.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(playback): close the keyboard when a subtitle percentage is done

Pressing Done in the Text Opacity or Background Opacity field committed the
value but left the field focused and the keyboard open over the sheet. Done
now commits, clears focus, and hides the keyboard. Commit on blur stays, and
the row skips the repeat commit that clearing focus triggers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): drop a revision probe answered by a different server

The capabilities request goes to whichever server is active when it is
sent. A server switch during the fetch cached the new server's revision
under the old URL, so the flusher could strip textOpacity for a server
that stores it, or send it to one that rejects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): start text opacity pickers at a readable value

1% reads as no subtitles. The HUD now offers 25-100% and Settings 5-100%,
matching the tvOS app; a stored value between steps is still offered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(settings): point the vendored contract at its merged server commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kamil Gielas <kamilgielas@MacBook-Pro-Kamil.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…o-Server#423)

* feat(detail): show the same external ratings on every title page

Title pages now show the server's ratings row: each source's mark and score
in the order the server sends them. On the phone and TV detail pages the row
replaces the IMDb-only token. TMDB's mark is its approved logo, and every
other source's mark is its name as dimmed text. Screen readers hear "IMDb 8.5".
A server without the row falls back to IMDb, then TMDB, never Rotten Tomatoes.

On a TV series page, the ratings stay in the hero when an episode is focused.
The TV focus marquee shows one rating with its mark, IMDb or else TMDB,
where it used to show a bare number. Request pages show the TMDB vote
average as a TMDB entry.

Poster rating badges now carry their source in the label ("IMDb 8.5",
"RT 93%", "RT Audience 95%") and draw no icon, so the tomato mark is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(detail): show at most three ratings on one line on phones

With an administrator's extra rating sources turned on, the phone title
page wrapped the external ratings onto a second line: Back to the Future
with Rotten Tomatoes and Metacritic on showed three entries with two
more underneath.

The phone layout now shows the first three ratings in the server's
order, on one line. When even those do not fit (a narrow screen, large
text, or a long source name from a plugin), whole entries drop from the
end instead of wrapping or being cut off. The tablet and fold layout and
Android TV still show every rating. Web and iOS apply the same limit.

The limit is ExternalRatings.PHONE_LIMIT with a forPhone helper, covered
by a unit test. The TV's single-line row that drops whole tokens moves
to android-shared so both apps use it; it relies only on stable Compose
layout APIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(detail): keep formats visible and one bad rating from breaking detail

- TV hero: ratings drop before playback formats and sources when the
  facts row runs out of width.
- The hero, Home marquee and TV request line share one facts row, so
  all three drop whole tokens instead of clipping; a long episode title
  still ends in an ellipsis.
- Decode the ratings list entry by entry and make score optional, so a
  malformed entry no longer fails the whole item detail.
- Expanded phone hero: ratings sit on one line, and the text block takes
  only the height the actions leave.
- Phone request: no availability-label fallback when a TMDB rating shows.
- Move the poster-badge label changes out of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(detail): let any TV rating drop first and wrap tablet ratings

- WholeTokenRow now places "·" separators itself, only between the
  tokens it shows, so the TV facts row can drop a rating that comes
  first without leaving an orphaned divider.
- The tablet and foldable hero wraps its ratings again; the text block
  is already clipped to the height the actions leave.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(detail): keep the ratings row visible on foldable and small tablet layouts

Beside a portrait poster, the expanded detail hero gave its whole text
block a fixed share of the height and clipped it, so at fold and small
tablet widths (about 664dp) a tall title logo pushed the ratings row out
of view. Only the title is weighted now: the facts and ratings rows are
measured first, and the logo scales down to the height that is left.
Play still lines up with the bottom of the poster.

The server now sends at most three ratings, so the tablet row no longer
wraps. Phone and tablet share one single-line row that drops whole
entries from the end when space runs out, matching TV.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): let genres give way before ratings in the detail facts row

When the TV detail facts row ran out of width, every rating dropped
before the type and genre words, so Back to the Future lost RT and the
Moon Knight series page kept only IMDb. The source tokens (type,
genres, episode numbers) now give way first, then ratings from the end
of the list, so IMDb stays longest. Other facts and any playback formats
stay visible, as before. This matches tvOS.

WholeTokenRow's dropFirst takes a rank so a row can drop one group of
tokens before another.

The foldable and small-tablet hero also gets a 12dp gap between the
ratings row and Play.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(ratings): say every layout shows ratings on one line

The PHONE_LIMIT doc still said tablet, fold and TV layouts show the whole
ratings list. They now share the one-line row that drops whole entries
from the end, and the server sends at most three ratings. PHONE_LIMIT
stays as a guard against an older server that sends more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Silo-Server#439)

* feat(requests): make requests look and behave like the rest of the app

Port the iOS and tvOS Requests redesign (silo-apple Silo-Server#558, Silo-Server#561, Silo-Server#540)
to Android phone and Android TV.

- Shared: the server's `state` decides display status; a four-step
  progress track; My Requests buckets; a session detail cache with
  prefetch; admin approve/decline/retry with a 60-second hold after a
  lost response; create, cancel and moderation are never resent.
- Phone: large-title hub with a status summary, My Requests in grouped
  rows with a filter menu and swipe to cancel, a new Approvals page,
  and request detail on the library title hero.
- TV: Requests becomes a top-bar tab in the Skyline layout, with a
  preview panel that shows status and progress, admin rows, and
  request detail on the library detail hero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(requests): address review findings on request state and routing

- Clear request session state on every feature reset (sign-in, sign-out,
  server and profile switches) and drop reads or mutation broadcasts that
  answer after a reset.
- Approval reads: a newer read wins, and a read sent before a decision
  can't bring the decided row back.
- Unconfirmed create and cancel holds now end only when a read shows the
  result, or after 60 seconds, like admin decisions.
- A proxy's 502/503/504 without the server's problem body counts as an
  uncertain outcome.
- Request detail reuses a /requests/mine read from the last 30 seconds,
  re-reading only to settle an uncertain action; the hub counts pending
  approvals from one page; a page's own decision no longer triggers a
  second read.
- TV long-press actions are computed by the caller, so the dialog no
  longer writes parent state during composition.
- RequestRouter moves to android-shared and handles every request tap on
  phone and TV, including both search screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(requests): address PR review findings

- TV: each request detail opened from another one gets its own
  back-stack entry and view model, instead of reusing the current one.
- TV: a failed action's message no longer replays when the page
  returns, and an approval queue that fails to load says so.
- Request detail applies a create, cancel or decision result as soon
  as the server returns it, so a failed re-read can't bring back
  Request, Cancel or the decision buttons.
- A failed or declined title the server calls requestable offers
  Request again instead of a blocking status.
- A /requests/mine read that a create or cancel overtook keeps the
  patched list and doesn't count as fresh.
- Phone: My Requests pull-to-refresh also refreshes the approvals
  card; grouped rows are separate lazy items; the row error text comes
  from the caller.
- FEATURES.md no longer says the admin stats dashboard is exposed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fline display (Silo-Server#432)

* feat(downloads): save poster and series artwork with downloads for offline display

Download rows loaded the catalog's signed poster URL, so offline they
showed only the ThumbHash once Coil's cache no longer held that URL.
Fetch the poster and series poster listed in the offline manifest with
each new download, store them with its assets, and show them in the
Downloads tab tiles. Episode rows use the series poster, never the 16:9
episode still.

Closes Silo-Server#431

* fix(downloads): refuse redirects on offline artwork fetches

The server streams artwork itself, so a redirect is never legitimate.
Following one would fetch a URL the same-server check never vetted.

* fix(downloads): refresh tiles when only the poster ThumbHash is saved

The saved-artwork query skipped rows whose only artwork was posterThumbhash, so a movie whose poster fetch failed never triggered a Downloads reload and kept a blank tile.
The library sort entry labelled Year was named ReleaseDate and sent
year, under a stale note that the server lacked release_date. Rename
it to Year (still sends year, so saved sorts restore unchanged) and add
a Release Date entry that sends release_date, newest first. Library
browse and collection grids list both; favorites and watchlist keep
Year only.
…lo-Server#437)

* feat(detail): mark a whole season watched on Android phone and TV

Phone: long-press a season chip, or use the series overflow menu, to mark the selected season watched or unwatched. TV: the More dialog adds the same entry while a season tab is selected. Both send the season id to the existing watched endpoint, update optimistically, roll back on failure, and re-read the season list, series hero, and visible episodes afterwards.

Season and series writes now clear this device's older resume positions for the episodes they reached, and marking a whole series refreshes the episode list and season state on both apps.

Closes Silo-Server#435
Closes Silo-Server#436

* fix(detail): read the server fresh after a season or series watched change

After a watched write, the season list and episode refreshes now skip the cached fallback and coalesced requests that still hold the pre-write state (CatalogRepository fresh reads). Resume positions are also cleared for a season whose episodes were not loaded yet, and the TV rollback keeps episodes marked on their own while the season write was pending.

* fix(detail): undo an episode's failed write in a failed season rollback

When both a season write and an overlapping single-episode write fail, the episode's own rollback restores the season's optimistic state. The season rollback skipped that episode as changed since it began, leaving it marked. It now restores episodes whose latest individual write failed.

* fix(detail): keep an episode's last successful write in a failed season rollback

A failed latest episode write does not mean every episode write since the season write began failed. The season rollback now skips any episode with a successful individual write since then, which the episode's own rollback already left showing.

* fix(detail): restore episodes with no newer write in a failed season rollback

An episode write admitted before the season write but finishing after it changed the phone's success record, so the season rollback skipped an episode it should restore. Restore whenever no episode write was admitted after the season write began.

* fix(detail): clear resume state and respect series writes in season watched changes

Marking a season or series on phone now clears the loaded episodes' resume points along with setting played, as TV already did, so Resume does not offer the old position if the follow-up read fails. A season write that fails after a newer series write began re-reads the server instead of restoring its stale snapshot, on both apps.

* fix(detail): tighten resume cleanup and overlapping series writes

Resume cleanup after a season or series write now checks the write's identity generation, so a server, account, or profile switch in between cannot clear another profile's rows, and a series write also clears seasons that were not loaded. A season rollback treats a newer series write as superseding only if it has not failed, and a successful series write leaves episodes changed on their own since it began.

* fix(detail): apply a series write to episodes whose newer write failed

A successful series write skipped every episode with an individual write admitted since it began, including writes that failed and rolled back. It now skips only episodes whose newer write did not fail.

* test(detail): cover a series write after an episode's failed write

* fix(detail): keep an episode's successful write when a series write lands

A successful series write now leaves an episode that had a successful individual write since the series write began, even if a later write to that episode failed, matching the season rollback rule.

* fix(detail): compare episode successes by admission order on phone

A late success from an episode write admitted before a series or season write counted as a newer change on phone. Compare the successful write's admission generation with the one captured when the parent write began, as TV already does with its watermark.
…ilo-Server#429)

"Download series" ignored the batch endpoint's result, so a request the
server rejected (such as the concurrent download limit) looked like
nothing happened. Single downloads only reported success or failure.

Both now report the start, and a rejected request shows the server's
message in a toast, with a fallback when it has none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#438)

Turning off "Card overlay badges" in the web settings stores the
profile setting ui.card_overlays_enabled, which the Android apps never
read, so badges kept showing on posters. The overlay store now reads it
alongside ui.card_overlays and resolves "show badges" the way the web
does: the profile's choice when it has one, otherwise the server-wide
default from overlay-config. That default is no longer treated as a
hard off, so a profile that turns badges on sees them too.
…TV (Silo-Server#430)

* feat(settings): add the title art preference on Android phone and TV

Read and write ui.title_art (settings manifest revision 16) through one
app-wide store. When title art is off, detail heroes, the TV marquee and logo
prefetch use the text title instead of logo art. Settings → Interface on the
phone and General on TV get "Show title art" and "Apply to all devices", with
a note when the profile-wide value is in force. Phone heroes put the title
type and studio or network on one eyebrow line.

Vendors the revision 16 manifest and conformance fixture.

* fix(settings): pin one profile across a title art change

Every request in a title art change (the PUT, the profile DELETE and the re-read) and every refresh read now carries one captured auth scope, the way the settings flusher pins its writes. A profile switch part-way through makes the remaining requests fail as identity_changed instead of changing the new profile's value.

* fix(settings): drop title art changes queued behind a failed write

When a title art write fails, changes queued behind it are dropped instead of sent with the failed change's unsaved scope, the store returns to the confirmed state and re-reads the server, and only server-confirmed values are cached. A refresh now captures its generation before reading the profile and auth scope, so a profile switch during those reads can no longer paint or cache the previous profile's value.

* fix(settings): stop a stalled title art write blocking the next server

Each server or profile now serializes its own title art writes. A write still on the wire for the previous identity keeps its own lock, and its completion is discarded by the generation check, so the new identity's saves go out at once.

* fix(settings): show failed title art saves on phone and TV

The store now exposes saveError, set when a title art write fails and cleared by the next successful save, a fresh read when Settings opens, or an identity change. Both settings surfaces show it under the switches, the same way the skip-interval controls show their save errors.

* refactor(detail): keep the phone hero eyebrow and marquee layout unchanged

Move the MOVIE · STUDIO eyebrow line and the marquee synopsis rule to their
own change so this pull request covers only the title art setting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): keep title art toggles working through cast sessions and cold starts

- A remote-playback overlay moves the identity generation without changing
  the profile, so the write guard now compares server, credential epoch
  and profile instead of rejecting the toggle as a profile change.
- The switches stay disabled until this session's read lands, so a toggle
  never picks its scope from a stale cached 'Apply to all devices'.
- Concurrent hydrate and refresh calls share one load instead of each
  sending capabilities and effective-values requests.
- Settings refreshes once when it opens, not each time the LazyColumn item
  scrolls back into view (which also cleared a shown save error).
- Drop isSaving and lastError, which no screen read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(settings): gate title logos through one helper

Logo surfaces call titleLogoUrl instead of each reading LocalShowTitleArt,
and the local is no longer static, so the cold-start flip from the default
recomposes only its readers instead of the whole app tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(startup): stop TV first-row warming from waiting on title art

The warm read the store's current answer (cached, else logos on) instead
of awaiting two settings requests before any backdrop was fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(settings): share the hashed cache key between settings stores

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): keep title art saves working across cast overlays

The write guard compared credential epochs between any two snapshots, but
a remote-playback overlay's snapshot always carries epoch 0 while the saved
account's carries its sign-in epoch. A read on one side of an overlay start
or end therefore failed every toggle on the other side with "The acting
account or profile changed", on the same profile. The epoch is now compared
only between two saved-account snapshots, and server ids are matched the
way the overlay handoff matches them.

A load also took its cache key from separate profile and server reads and
its request authority from a third read, so an overlay starting or ending
between them could cache one profile's answer under another's key. The key
now comes from the same snapshot the load is pinned to.

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e apps (Silo-Server#445)

* feat(settings): lay out Android and Android TV Settings like the Apple apps

Settings on the phone was one long scrolling form of web-style cards, and the
TV settings had drifted from tvOS: mono section headers, an eyebrow above each
pane, a narrow rail without descriptions, and different labels and grouping.

Phone, following the iOS Settings (silo-apple Silo-Server#546):
- The overview is an inset-grouped list on black: large title, account card
  (avatar, "username · host", Admin badge), search, then one-line rows with
  graphite icon tiles and the current value. Sections: Preferences, Playback,
  Library, Support, Connection, Experimental, About, Sign Out.
- Interface, Playback, Subtitles, Downloads, and Notifications are pushed
  sub-pages (new settings/* routes sharing the overview's view model), with
  Apple's labels, group order, and footers. Subtitle appearance is edited
  inline in Text, Background, and Layout groups under a live preview.
- Diagnostics follows the Apple page order with menu pickers.
- Rows use Apple's grouped palette: #1C1C1E cells, #3A3A3C tiles, green
  switches, title-case headers, and popup pickers with a checkmark.

TV, following tvOS Settings:
- Black ground, a wider rail with tvOS rail descriptions and outlined
  glyphs, and a pane header with the category glyph on a graphite tile.
- Sans section headers, tvOS-proportioned rows, a card-style option picker,
  and the tvOS confirmation card. Text keeps the 14sp TV floor.
- Playback moves Audio Language into Streaming; Subtitles orders Language,
  Behavior, Forced, splits out Metadata, and says Use Device Settings; Server
  shows Address, App Version, and Privacy Policy.

Android-only settings stay, placed in the matching group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): keep group dividers right when rows change, and restore Diagnostics privacy link

Row dividers were assigned by first-composition order, so a group whose first
row changed while it stayed on screen (search filtering, a row swapping in
place in Diagnostics) drew a hairline on its top edge. Every row now draws its
hairline and the group paints over its own top edge.

The phone Diagnostics page lost its Privacy Policy link in the restyle; it is
back as the last Capture row. The subtitle preview parses colours with
toColorInt, as lint asks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): preview the device caption style when Use Device Settings is on

The phone subtitle preview drew the saved custom style even with Use Device
Settings on, while playback and the TV preview use the device caption style.
The preview now reads effectiveSubtitleAppearanceFlow; the Text, Background,
and Layout groups still edit the saved style.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): keep Settings rail text on one line at large font sizes

Rail rows are a fixed 50dp. At font scale 1.3, "Language and appearance"
wrapped and its second line was cut off mid-glyph. Title and description now
ellipsize on one line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…V sign-in (Silo-Server#448)

* feat(auth): sign in with the server's identity provider and the new TV sign-in

The phone signs in through the server's OIDC provider in a Custom Tab, offers
"Not you? Switch account", and connects or disconnects providers in Settings.
The TV shows an 8-digit code with a QR, follows opened and canceled states,
and hides the password form on provider-only servers; phones approve its code.
Every sign-out now runs one shared teardown, which also clears title art.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): bind provider flows to the saved login and fix two TV approval gaps

A provider sign-in or link left open in the browser now records the saved
server's durable login id when it starts. The redirect is redeemed only while
that login is unchanged, so a password sign-in or sign-out in between
supersedes the flow, and a link still finishes after Android recreates the
process. Password sign-in and sign-out also discard the pending flow.

A silo://device?token= link can retry its lookup with Continue, and the nearby
TV approval card names the account it signs the TV in as.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pairing): fail fast on TV refusals and stop stale sign-in work

The phone now reports a TV's failed result that arrives before any code
(a different server at that address, an expired code) instead of waiting
out the 30-90 second timeout. A pairing connection that ended after a newer
advertiser start no longer touches that start's listener or busy flag.

The nearby TV card goes away and its pairing is cancelled when the route
hides the offer. In Sign-in settings, Cancel stops a link ticket request
still under way, and identities are shown only while the same login is
signed in. A TV approval whose sign-in context changed no longer cancels a
password attempt made since.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): keep TV approvals on the server and account they started with

The TV polls a resumed code only while the sign-in context it was started
under is unchanged, and withdraws an expired code that failed to poll before
asking for a new one. On the phone, a code lookup goes only to the server
chosen when it started, a token link always uses the active server, an
unknown lookup status offers no decision, and nearby approval stops when
the card can't name the account. Provider discovery for a URL no longer
depends on the active server's update verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): drop results from superseded sign-in flows and stale pairing runs

A provider redemption now publishes its outcome only while its flow is
still the latest: starting a new flow, or a sign-in or sign-out discarding
the pending one, silences it, and discarding also clears an unshown
result. On the TV, a pairing run that ends after a newer connection took
over the receiver leaves that connection's push, session and status alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): don't spend a pinned bearer after its scope is gone

When a fresh-auth request's refresh fails and its scope was signed out or
replaced meanwhile, the request now fails instead of going out with the
bearer captured before. On the TV, an advertiser startup superseded by a
later start or stop closes its socket instead of publishing it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): let a nearby phone hear the sign-in result before leaving

When a nearby phone approved the code, the sign-in screen now waits up to
five seconds for that session to finish its result and done exchange before
routing on, so the phone doesn't report a failed pairing for a TV that
signed in. A completed session routes on after its own dwell as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): keep the nearby wait from cancelling its own fallback route

Consuming the sign-in success flag changes the effect's key, so doing it
before the wait cancelled the wait and its fallback route. It is consumed
after the wait now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): show a link's result only on the server it was made for

Sign-in settings now ignores a provider link result for another saved
server, so switching servers before opening Settings doesn't show one
server's "Connected" or failure on the other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): recheck the server before saying a link connected

The "Connected" message after a provider link is shown only if the link's
server is still the active one once the section has reloaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pairing): keep stale attempts out of the connection that replaced them

A TV pairing attempt that outlived its connection (its session save can't
be cancelled) no longer reports its result into the newer connection. The
"Connected" message after a provider link also requires the same login,
not only the same server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pairing): hide the nearby TV offer over audiobooks and the reader

The nearby-TV card and its discovery are now off on the audiobook player
and book reader routes too, not only the video player and cast remote.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): name the provider in "Use a different account"

The link under the provider buttons picks another account at the identity
provider, not another way into Silo, which read wrong on a server with
password sign-in off. With one provider it now says "Use a different
<provider> account"; with several it stays generic and opens the chooser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member PRs and default-branch builds ran on the private Linux runners.
Route them to GitHub-hosted runners while the private runner setup is
rebuilt; the reusable workflow already supports the hosted path, which
non-member PRs use today.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r#454)

* feat(auth): sign in with network identity on phone and TV

When the server lists a network provider (such as the Tailscale plugin)
for this device, phone and TV offer "Continue as <owner>" above the other
ways in. One press posts {} to the listed network_sign_in_path on the
saved server base and saves the returned token pair exactly as a password
sign-in does. The TV button takes initial focus; the device code, QR and
password form stay available. Refusals (network_identity_required,
not_permitted, email_in_use and the shared ones) have readable text that
names the provider.

Phone Settings > Sign-in offers "Connect <provider>" for a listed network
provider when getExternalSignInCapabilities reports network_sign_in,
asking only for the account password (linkAccountIdentityWithNetwork).

Vendors only the network sign-in contract fixtures and the updated
capabilities fixture, and adds them to the sync script's selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): free the network button after a device approval and offer it in the TV password form

A phone approval that cancelled a pending "Continue as" sign-in left the
network button busy when saving the approval failed, which also blocked
the password form. The device handoff now frees both buttons when it ends.

On servers without device sign-in, the TV opens on the password form,
which had no "Continue as" button. The form now shows it above the fields
and focuses it, including when the providers answer before the device
check. The two-line button uses less vertical padding so the form still
fits a 960x540dp screen.

The TV's email_in_use message for network sign-in no longer points to
Settings, which the TV does not have. It points to the phone or web app.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): stop a cancelled link and keep busy network sign-ins blocking

Cancel in the Settings directory and network prompts now stops the link
call still under way, like the browser prompt already did, and reloads the
section so a link the server made first still shows. Before, the link kept
running and could report "Connected" after the prompt was gone.

On the phone, "Continue as" keeps its own busy flag. Reloading the sign-in
options no longer frees the password form and provider buttons while the
request is still out.

On the TV, "Sign in with a password" waits while "Continue as" is signing
in, so the form can't hide the "Signing in…" label. Once "Continue as" is
refused, the device code's recovery action ("Try again", "Show a new code")
takes focus again.

Cleanups: the phone's network refusals in Settings map through
NetworkSignInFailure and share their copy with the sign-in screen; the TV
error text takes one provider name for the attempt; the network sign-in
call trusts the path discovery already validated; and the unused
OAuthHandshakeCapabilities.networkProvider is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): keep a late Sign-in reload from undoing a newer one

Cancelling a link prompt reloads the Sign-in section, and so does every
link that succeeds. If the cancelled prompt's reload answered after the next
link's reload, it showed the identities from before that link. Each reload
now takes a generation, and only the newest publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(contracts): re-vendor the API v2 fixtures from the network sign-in server commit

SOURCE named the earlier OIDC server commit, which doesn't contain the
network sign-in fixtures. A full re-sync from the server branch that adds
them reproduces every vendored body; the only other change is the system
info fixture's contract digest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): disable the TV password form while Continue as signs in

On a server without device sign-in, the password form shows "Continue as"
above the fields. The view model already ignored a password submission while
the network sign-in waited, but the fields, the IME Done action and Sign in
stayed enabled, so Sign in looked live and did nothing. They now follow one
signingIn state (password or network busy) that the view model's guards also
use. Focus stays on the waiting "Continue as", and the D-pad steps between it
and the secondary actions past the disabled controls.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): disable the phone password form during any sign-in

The phone view model ignores a password submission while any sign-in is
under way (signInBusy: password, browser provider start, or Continue as).
The Sign in button only followed the network wait, though, and the fields,
the show-password toggle and the IME Go action never did, so they looked
live and did nothing. All of them now follow signInBusy, as the TV form does.
AuroraTextField gains an enabled flag that dims its text when off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ver#380)

* fix(tv): play phone-launched titles with sequenced playback

Sequenced playback required a saved login, so a title a phone launched on
the TV under its temporary remote-playback profile failed to start with
identity_unavailable. Attempts admitted under a temporary identity now run
in memory only: their credentials die with the process, so they stay out of
the durable journal and recovery, and only the identity that started an
attempt may act for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): settle a phone's uncertain start before its next title

The start fence compared pending attempts with the saved login's marker,
so a start sent under a temporary remote-playback identity whose reply
was lost was never settled: the next title started beside an orphaned
server session. The fence now uses the current identity's marker, and a
temporary identity settles its own uncertain attempts (replay the start,
then stop) with its own scope before admitting another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): drop an ended remote-playback identity's attempts

A temporary attempt whose identity ended before its stop was confirmed
never settled, so compaction never removed it: each interrupted handoff
kept its session and history for the life of the TV process, and owns()
kept answering for it. Saving now drops temporary attempts that don't
belong to the current identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): forget an ended remote-playback identity's captured scope

Dropping the attempt from the journal left its captured scope, adoption
and subtitle headers (which hold the bearer header) in memory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): don't restore the scope of an attempt pruned while saving

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): report an identity change when a phone's progress retry is pruned

If the phone's remote-playback identity ended while a retried progress
sample was in flight, saving the receipt dropped the attempt and the
follow-up lookup threw, so the player saw playback_storage instead of
identity_changed. Look the attempt up nullably and answer identity_changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Silo-Server#459)

* feat(onboarding): redesign the first-run flow on Android phone and TV

Port of the Apple first-run redesign (Silo-Server/silo-apple#586): one
brand-light backdrop behind server setup, sign-in, profiles and PIN on
both apps, tinted to the server's accent and to the selected person.

- Phone: address field with Advanced options and Recent servers, a
  "Sign in" screen with the server chip and branded subtitle, round
  profile avatars sized to the household, full-screen PIN keypad.
- TV: phone-first setup with three picture steps, a separate address
  screen, sign-in with the code card on the right, restyled pairing,
  picker and PIN.
- A saved server that still has a session goes straight to profiles.
- After a sign-in, a one-profile household with no PIN skips the picker.
- Branding (subtitle, accent, mark) is read from the public branding
  endpoint; asset URLs are accepted only on the server's own origin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): keep the server address fixed while it is probed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…er#444)

* fix(downloads): refresh saved subtitles the server retimed

A video download saves its subtitle sidecars once, when it completes. When
the server later syncs or resets a stored subtitle's timing, the download's
manifest lists that subtitle at a new revision, but the saved copy kept the
old timing, so offline playback stayed out of sync.

Saved sidecars now record the manifest fetch_url and revision. A periodic
worker (every 12 hours with a network) re-reads the manifest of each
completed download of the active login and profile, re-fetches only the
sidecars whose revision changed, replaces each file in place, and records
the new revision. Captures saved before this change carry no fetch_url and
are left as they are.

Refs Silo-Server#442

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): publish refreshed subtitles only while the download is unchanged

The refresh worker wrote each re-fetched sidecar over the saved file before
it checked that the download was still the one it read. A download replaced
or deleted meanwhile could have a fresh capture's subtitle overwritten with
the old download's bytes, leaving its recorded revision wrong.

Stage the re-fetched sidecars beside the saved files instead. The worker
publishes them inside its generation and ownership check, and only over a
saved copy that is unchanged since staging; whatever is not published is
deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): hold one lock per download while subtitles are captured, refreshed, or deleted

A subtitle refresh checked the download's row and its saved file, then
replaced the file and rewrote the row in later steps. A track capture for
the same download could write new sidecars in between, and the refresh
would move the old subtitle's bytes over them. A delete landing between the
refresh's read and write let the refresh write the deleted row back.

The capture, the refresh, and both delete paths now hold a process-wide
lock per (server, profile, file) across their check and writes. Each takes
it before the identity barrier or a database transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): record refreshed subtitle revisions only while the row is unchanged

The subtitle refresh checked the download's row and wrote its new revisions
in separate statements. Re-downloading the same file writes the replacement
row without the identity barrier or the slot lock, so a replacement landing
between them was overwritten with the old download.

The check, the file publish, and the row write now run in one Room
transaction (DownloadMetadataStore.updateSidecarIf), like
completePendingDeletion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o-Server#464)

* feat(playback): play embedded MKV text subtitles from the stream

Advertise native embedded subtitles for Matroska SubRip and ASS tracks on
original-HTTP delivery, identified by container_track_id. Media3's
MatroskaExtractor sets Format.id to the Matroska TrackNumber, which the
server records as container_track_id, so the existing exact-ID selection
works unchanged. The MKV entry reports libass styling and font-attachment
support so the planner keeps ASS on the sidecar path when fidelity would
be lost.

Closes Silo-Server#457

* docs(playback): note MKV stream-path costs and tidy capability entry

Import NativeEmbeddedSubtitleCapability, drop the test that duplicated
existing exact-ID coverage, document the legacy S_ASS/S_SSA and seek
costs, and stop describing the open server PR as shipped.

* docs(playback): note that legacy S_ASS/S_SSA tracks stay on sidecars

* fix(playback): accept native MKV subtitle plans in the Media3 validator

validateForMedia3 still required mov_text in an MP4/MOV/M4V source, so a
native embedded plan for an MKV SubRip or ASS track was rejected with
subtitle_embedded_failed and replanned to a sidecar. Accept the pairs the
client advertises and keep rejecting the rest.

* refactor(playback): define native embedded subtitle pairs once

The capability detector and validateForMedia3 each hardcoded the
container/codec pairs Android selects natively, and their drift is what
rejected MKV plans. Both now read NATIVE_EMBEDDED_SUBTITLE_CODECS. The
validator also accepts the srt and tx3g aliases the server normalizes and
drops the mov/m4v containers the server never reports.

* docs(playback): state the container track ID assumption for native pairs
…late (Silo-Server#465)

* fix(tv): offer the full subtitle-language list in search and AI translate

Android TV kept its own 28-language list, so a Croatian (hr) preference
fell back to English in the AI translation picker. Move LanguageNames to
android-shared and derive the TV pickers from it, matching the phone app.

Fixes Silo-Server#271

* fix(tv): match audio and target languages across 2- and 3-letter codes
* fix(playback): stop claiming m2ts/mts direct play

Media3's TsExtractor reads only 188-byte transport-stream packets.
Blu-ray and AVCHD .m2ts/.mts files use 192-byte BDAV packets, so no
Media3 extractor recognizes them and direct play fails with
ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED before recovering through a
failure replan to an HLS remux.

Drop m2ts and mts from the advertised direct-play containers. The same
list feeds the top-level client capabilities, the original_http
delivery capability and the download capabilities, so the server now
remuxes these files from the start and a download no longer fetches
original bytes the player cannot open. ts, mpegts and mpeg-ts stay.

The video/mp2t MIME mapping for m2ts/mts in SiloPlayerFactory stays: it
is only a hint used when the server still serves original bytes (for
example an older server that ignores the capability list), it is the
correct MIME type for the format, and it does not change the outcome.

Related to Silo-Server#449

* docs(playback): scope the m2ts note to what the server remuxes

The container policy comment said dropping m2ts/mts keeps every download
from fetching the original bytes. Whole-series downloads at Original
quality still do, because the server does not check device capabilities
for that batch. Say so, and split the M2TS row in the playback validation
gates so M2TS/MTS is expected to use server adaptation rather than direct
play.
…lo-Server#443)

When the server syncs or resets a stored subtitle's timing, it sends
subtitle_timing_changed to the file's playback sessions and serves the new
timing from the same sidecar URL. Media3 keeps the cues it already parsed,
so a subtitle selected before its sync finished stayed off by the old
offset until playback restarted.

Handle the event on phone and TV: when the retimed subtitle is the mounted
sidecar, remount the same item at the same position so the cues are fetched
again. Stored subtitles are matched by download ID or by the
downloaded_subtitle_id the server pins on a v3 sidecar URL.

Refs Silo-Server#442

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ubtitles (Silo-Server#446)

* fix(player): reload a subtitle the server retimed during playback

When the server syncs or resets a stored subtitle's timing, it sends
subtitle_timing_changed to the file's playback sessions and serves the new
timing from the same sidecar URL. Media3 keeps the cues it already parsed,
so a subtitle selected before its sync finished stayed off by the old
offset until playback restarted.

Handle the event on phone and TV: when the retimed subtitle is the mounted
sidecar, remount the same item at the same position so the cues are fetched
again. Stored subtitles are matched by download ID or by the
downloaded_subtitle_id the server pins on a v3 sidecar URL.

Refs Silo-Server#442

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(subtitles): show sync status and sync/reset actions for stored subtitles

silo-server#1783 aligns stored subtitles to the file's audio and lets the
account that added a subtitle, or an admin, start a sync or reset its
timing. The Android players showed none of it.

Phone and TV now read each stored subtitle's timing and latest sync job
and show it in the web player's words ("Syncing…", "Synced −3.2 s",
"Synced −23.7 s · ×1.0008 speed", "Already in sync", "Doesn't match this
video", "Sync failed"). For the selected stored subtitle they offer "Sync
subtitle" and "Reset timing": in the phone's Audio & Subtitles sheet, and
as a Timing row in the TV HUD's Subtitles pane. A running job is polled
until it ends. A refusal (403) replaces the actions with a short
explanation; unsupported formats (422) and stale versions (412) say so.

A shared StoredSubtitleSyncController holds the state for both apps;
SubtitleSyncV2Api covers the capability probe, sync, sync status, and the
If-Match timing reset. The TV picker no longer marks an option selected
when none is current, so an action menu shows no checkmark.

Refs Silo-Server#442

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(subtitles): sync subtitle files next to the media, with live progress, on phone and TV (Silo-Server#463)

* fix(downloads): refresh saved subtitles the server retimed

A video download saves its subtitle sidecars once, when it completes. When
the server later syncs or resets a stored subtitle's timing, the download's
manifest lists that subtitle at a new revision, but the saved copy kept the
old timing, so offline playback stayed out of sync.

Saved sidecars now record the manifest fetch_url and revision. A periodic
worker (every 12 hours with a network) re-reads the manifest of each
completed download of the active login and profile, re-fetches only the
sidecars whose revision changed, replaces each file in place, and records
the new revision. Captures saved before this change carry no fetch_url and
are left as they are.

Refs Silo-Server#442

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(subtitles): sync subtitle files next to the media, with live progress, on phone and TV

The server now syncs any playable subtitle to the file's audio, stored or a
sidecar, keyed by the inventory's sync_key, and reports each step over the
playback socket. Follow that on phone and TV:

- Shared: sync API by sync key (list, read, start, set timing with If-Match),
  sync_key on inventory rows, subtitle_sync_updated and keyed
  subtitle_timing_changed decoding, SubtitleSyncController (realtime with a
  3 s polling fallback, jobs this viewer started, one cue reload per timing
  change), and a port of the web player's feedback state machine.
- Phone: per-track status lines, a Timing section with progress, phase,
  result, reset and the sidecar note, and a non-blocking status card.
- TV: status lines in the track picker, a Timing row with progress and
  result, and the same card while the HUD is closed. The HUD sync state is
  built in the view model to keep TvPlayerScreen within ART's JIT limit.
- Offline: saved sidecars refresh on a new manifest revision too, but not
  when their position now names another file.
- Player: a sidecar remount right after a track switch no longer crashes in
  NonGatingSidecarPeriod (continueLoading without a pending reset).

Refs Silo-Server#442

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(subtitles): name merged stored subtitles for sync

Rows merged from the stored-subtitle list (sessions without an inventory,
the phone's fresh-subtitle restore, and TV refreshes) carried no sync_key,
so they lost the stored-subtitle status and timing actions. Name each one
stored-{id}, which the server treats as equivalent, for the formats it can
retime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv): show sync status for merged stored subtitles in the track picker

A row merged from the stored-subtitle list maps to a Downloaded identity,
which the HUD's status lookup ignored, so the picker showed no status for
it. Look those rows up by stored subtitle ID.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(subtitles): follow the selected track and wait for its cues in sync feedback

- The track on screen is the selected subtitle when the player has it
  mounted. A legacy session mounts every row, and taking the first mounted
  key could point the feedback at the wrong track.
- A mount's cue revisions count as loaded once Media3 reports a subtitle
  track selected on the live player, not as soon as the mount returns, so
  "Subtitles synced" waits for the reloaded track.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): refresh a positional sidecar only while its row still describes it

An external:{index} ref names a sidecar by its position, which can come to
name another file when sidecars are added or removed. Refresh such a row only
while it lists the language, title, format, and forced and SDH flags the
saved file was captured with. Stored downloaded:{id} refs are stable and skip
the check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): publish refreshed subtitles only while the download is unchanged

The refresh worker wrote each re-fetched sidecar over the saved file before
it checked that the download was still the one it read. A download replaced
or deleted meanwhile could have a fresh capture's subtitle overwritten with
the old download's bytes, leaving its recorded revision wrong.

Stage the re-fetched sidecars beside the saved files instead. The worker
publishes them inside its generation and ownership check, and only over a
saved copy that is unchanged since staging; whatever is not published is
deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(subtitles): keep the newer sync state when responses and updates cross

The server doesn't sequence its HTTP responses and realtime updates. A
start response that left before a fast job finished could arrive after the
outcome and rewind it to "pending": the status went back to Syncing, the
track reloaded twice, and the card could stay on its progress. Ignore a
snapshot of an older job, or of the same job further back, while still
following the job if this viewer started it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): hold one lock per download while subtitles are captured, refreshed, or deleted

A subtitle refresh checked the download's row and its saved file, then
replaced the file and rewrote the row in later steps. A track capture for
the same download could write new sidecars in between, and the refresh
would move the old subtitle's bytes over them. A delete landing between the
refresh's read and write let the refresh write the deleted row back.

The capture, the refresh, and both delete paths now hold a process-wide
lock per (server, profile, file) across their check and writes. Each takes
it before the identity barrier or a database transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(downloads): record refreshed subtitle revisions only while the row is unchanged

The subtitle refresh checked the download's row and wrote its new revisions
in separate statements. Re-downloading the same file writes the replacement
row without the identity barrier or the slot lock, so a replacement landing
between them was overwritten with the old download.

The check, the file publish, and the row write now run in one Room
transaction (DownloadMetadataStore.updateSidecarIf), like
completePendingDeletion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(subtitles): ignore a read sent before the subtitle state already shown

Only job progress decided whether a response was stale, so a response with
the same job, or none, always replaced the entry. Two quick timing changes,
such as an adjustment and then a reset, start two reads; if the first one
answered last, its old timing came back and the track reloaded again.

The controller now numbers each read as it is sent and each realtime update
as it arrives. A response from a read sent before the view on screen is
ignored, unless it reports a job that view has not reached: the server can
only have produced that later, and it may be a job this viewer just started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o-Server#379)

* fix(cast): keep the TV reachable after a TLS 1.2 client connects

SiloCastTls handed BouncyCastle one shared PSK array. BouncyCastle zeroes
the PSK buffer after a TLS 1.2 handshake, so the first TLS 1.2 client wiped
the key for the rest of the process, and every later handshake (TLS 1.3
included) failed with bad_record_mac until the TV app restarted. The key and
identity are now fresh arrays on every use.

A regression test connects two TLS 1.2-only clients and then an ordinary
client; it fails without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(cast): wait for the TLS test's server thread before cleanup

The regression test iterated the server sessions while the worker thread
could still be adding the last one. It now joins the worker (bounded) and
reads a synchronized list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-Server#381)

Android and Apple peers disagreed on parts of the Remote Control wire
format:

- A frame with a message type this build doesn't know failed to decode,
  and the decode error tore the session down. Unknown types are now
  skipped; the Apple apps get the same change. A known type with a broken payload
  still fails.
- The TV answered a command name it doesn't implement with
  player_not_ready. It now ignores it without an error, as tvOS does.
- The TV sent its aspect ratio as fit/zoom/stretch and the phone sent the
  subtitle position "standard"; tvOS uses fit/fill/stretch and "bottom" and
  rejects anything else. Episode subtitles now read "Series · S1 · E2".
- A discovered TV without a `v` TXT key was treated as protocol v2; like
  iOS, it is now treated as v1.
- Apple removed the HDR toggle. The phone remote drops it too, and the TV
  advertises supportsHDRToggle = false (the key stays on the wire because
  older remotes require it) and ignores set_hdr_enabled from older phones.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…stop (Silo-Server#481)

* fix(tv): end the remote-playback identity after the player's session stop

Backgrounding the TV app during phone-handed-off playback ended the
temporary identity while the player's queued final progress report and
stopSession were still in flight, so stopSession failed with 401.

The player's ON_STOP observer queues its teardown before
MainTvActivity.onStop() calls TvSiloCastReceiver.stop(). stop() now waits
(up to 15 s) for that queued teardown before ending the identity, so an
unconsumed identity is still always ended. The generation check moves
inside RemotePlaybackIdentityManager's lock so a delayed cleanup cannot
end a replacement identity.

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

* fix(tv): keep the deferred stop cleanup off a reused handoff identity

stop() now waits up to 15 s for the player's teardown before ending the
temporary identity. If the TV returns and the same phone hands off again
in that window, prepare() reuses the identity with the same generation,
so the generation guard alone let the old cleanup revoke it.

A reuse now bumps a claim count on the active identity, and the stop
cleanup ends it only if it is still exactly the identity it captured. An
offer that fails before claiming the identity leaves it to the cleanup,
so the identity is still always ended.

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

* test(tv): cover the remote-playback identity claim guard

Covers the three outcomes of stop()'s deferred identity cleanup: a
same-phone handoff that reuses the identity blocks it, an offer that
fails before claiming the identity still lets it end, and a replacement
identity is left alone.

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

* fix(tv): tie the stop cleanup's claim check to the receiver run

The claim-count snapshot could still leak the identity: a handoff from
the session stop() was tearing down could finish its reuse after stop()
took the snapshot. The cleanup then skipped, and with the receiver scope
gone nothing else ended it.

Each start() now begins a new receiver run, and a handoff tags the
identity with the run that installed or reused it. stop()'s cleanup ends
the active identity unless a later run has claimed it, so claims from
the stopped run never protect it. The cleanup is now always scheduled,
since a stopped-run handoff can install an identity after stop().

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…mental toggle (Silo-Server#377)

* docs(watch-party): plan the Android Watch Party against the current contract

The September 21 draft predated the server's buffering and catch-up
policy and Apple's merged client. This plan follows server main
2812ac39, the web reference client, and Apple main 5d9dc764, and lists
every Android input and session-replacement path the room must own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): adopt the current room contract in the shared room owner

The shared Watch Party layer followed an older server contract. Room
proof never renewed, the same-room rejoin reused an obsolete socket,
connection replacement was treated as a transport drop and reclaimed the
membership, suggestion hydration ran on the socket receive path, and
events could be dropped without notice.

The API now covers every v2 room operation with status and body
validation, caller-selected ids kept by the logical action, and a
single attempt for the non-retryable start, select, and promote. Host
operations authorize by identity; member operations send the room proof,
which is adopted from every successful room response.

WatchTogetherRepository owns one engagement per membership: it renews the
proof before expiry, orders HTTP snapshots against socket receipts so a
delayed response cannot erase a newer attachment, treats ended snapshots,
room_closed, and connection_replaced as terminal, serializes room and vote
actions and reconciles uncertain outcomes by reading the room, drains
suggestion pages off the socket path, and samples the server clock on the
room socket from the moment it opens. A socket that stayed up reconnects
at once after the server's five-minute rotation; quick failures keep
retrying through the host's two-minute grace. RoomSession keys
connections by membership generation, so an explicit rejoin replaces the
old socket.

The player controllers and entry ViewModels are patched to the new API
only; the player binding and shared lobby are rebuilt in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): cache availability and remember the recent party

Entry points need to know whether this server and profile can use Watch
Party, and a user who is displaced or loses the connection needs a way
back into the party.

WatchPartyAvailabilityRepository reads the room and playback capability
documents, caches the answer for the captured authority, and never lets a
failed probe downgrade a known answer. RecentWatchParties keeps one room
code per device in the encrypted preferences for 24 hours, shown only to
the server, login, and profile that saved it; no room or join token is
stored. The recorder keeps it after a replacement or lost connection,
forgets it when the room ended, and clears it before sign-out, account
replacement, or server removal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): add the shared room playback binding

The phone and TV room controllers each carried their own copy of the
sync mechanics, reported the intended rather than the actual player
state, never re-armed readiness for a second seek on the same session,
and had no support for the server's buffering and catch-up policy.

RoomPlaybackBinding runs one event loop per playback epoch against a
small player port. It attaches the committed session on every socket
epoch and waits for the server's echo, reports observed state every
1.5 s and stays quiet while stalled, seeking, or suspended, schedules
commands on the server clock and advances a late Play, acknowledges the
exact waiting command until a fresh snapshot confirms it, reports
buffering after a 2 s stall, acknowledges recovery while catching up,
and converges small drift by a bounded temporary rate. Media reloads
for Play corrections are paced by RoomReloadBudget; explicit seeks are
never delayed. The catch-up rules are ported from the web client.

The repository implements the binding's room interface. The platform
adapters follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): start room playback on the room's exact file and position

A room start could fall back to another version when the room's file was
missing, resume from the viewer's own progress when no position was
passed, and let a replan move the viewer onto a different file.

Room starts now carry a WatchPartyPlaybackContext. Both starters play
exactly the room's file or fail, start at the room position (an
explicit zero included), never apply personal resume or the resume
rewind, send allow_alternate_versions: false so every replan keeps the
file, and refuse a plan for a different file. StartParams keeps the
flag so a 404 renewal pins the same file. Solo starts omit the field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): add the player-side Watch Party coordinator

Both players need the same wiring around the room binding: the room's
playback context, status, transport requests, reconciliation of seeks
made outside the app, leave and end, and coordinated source fallback for
the four approved refusal reasons. WatchPartyPlayback provides it so
each platform keeps only a thin adapter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): share hub, lobby, and picker state between phone and TV

The phone and TV each had their own entry, lobby, and suggestion
ViewModels. They opened the player whenever a title was selected, held
a lone host in the lobby, created vote rooms from the menu, minted new
ids on every retry, and normalized codes differently.

Shared ViewModels now back both platforms. The hub probes availability,
hosts Host Picks rooms (staging a title without starting it), keeps a
create's room id for an explicit retry after an uncertain outcome, joins
by normalized code or invitation link (never sending a token to another
server), and offers Return to Party and Rejoin. The lobby exposes
eligibility computed from phase, role, capabilities, and pending work,
advisory lobby Ready, suggestions with Queue (stage, never promote), and
opens the player only when the room plays. The picker loads the server's
shared rows and debounces search.

Invitation parsing accepts codes, web links with a base path, and the
silo://watch-party app link proposed in silo-apple#412.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): add the device-local Experimental toggle

Watch Party ships off in release builds and on in debug builds, like
the Apple apps. WatchPartyExperiment keeps the choice on the device,
never syncs it, and leaves any active party when it is turned off. Both
apps inject it as WatchPartyExposure; the entry points switch to it with
the new party screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(watch-party): expose party state to the playback harness

Device runs need to correlate each client's room, socket, commands, and
catch-up state and to measure the spread between two players. The debug
receiver now reports sampling timestamps and a party object (room phase,
revision, connection epoch, attached and applied commands, clock offset,
correction rate) with no tokens or tickets, and the script gains party
and spread commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): never let the HTTP engine resend start, select, or promote

A new test shows OkHttp silently resending a Watch Party start when a
reused connection drops after the request was sent. These operations
are non-retryable on the server: a replay after another selection could
start the wrong item.

createPlatformHttpClient and createSiloClient take a
retryOnConnectionFailure switch, and the Watch Party API sends start,
select, and promote on a client with it off. The repository already
reconciles such uncertain outcomes by reading the room.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): drive the phone player from the room binding

RoomSyncController reported intended state, never re-armed readiness,
played downloaded copies, and let local controls and system media
controls fork the viewer from the room.

The phone player now uses WatchPartyPlayback. Room loads play exactly
the room's file at the room position, start paused, skip downloads, and
reuse the room context on every restart; version switching and On Deck
are refused. The ViewModel reports the true engine position mapped to
the source timeline and applies room seeks, play state, and a temporary
correction rate through paths that are never user intent. Every user
and system transport input goes through the room; seeks the screen did
not issue are sent to the room or undone. Audio focus loss, a noisy
route, the sleep timer, and backgrounding are local suspensions. Cast
and automatic PiP are off in a room, intro skip is pinned to Ask with no
pill for members who cannot seek, and approved refusals ask the room for
a coordinated fallback. Start refusal reasons now reach the ViewModel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): hold room commands during a mount and while suspended

A room Play reached a locally suspended player, and a command that
landed while media was still mounting acted on media being replaced.
The binding now leaves a suspended player paused (resuming re-attaches,
and the server resends the room position) and holds a command for up to
five seconds while a mount or seek is in flight; a late Play still
advances by the full elapsed server time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): drive the TV player from the room binding

TvRoomSyncController duplicated the phone controller's defects, left the
room when the TV app paused or stopped, and suppressed all SiloCast
transport in rooms, hosts included.

The TV player now uses WatchPartyPlayback. It loads exactly the room's
file at the room position, starts paused, and reuses the room context on
every restart; version switching, the Dolby Vision restart, speed writes,
and next-up are refused. The ViewModel reports what the player is really
doing and applies room seeks, play state, and a temporary correction rate
through paths that are never user intent. Remote keys, the scrubber,
skips, chapters, the intro pill, MediaSession, admin commands, and
SiloCast transport go through the room's permission decision; SiloCast
launches are refused while a party plays. ON_PAUSE/ON_STOP, audio focus,
and the sleep timer are local holds instead of leaving the room.
Approved refusals ask the room for a coordinated fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): refuse SiloCast handoffs while the TV is in a party

A handoff swaps in the phone's profile, and any identity change leaves
the Watch Party the TV is in. The receiver now cancels the offer with
watch_party_active and asks the viewer to leave the party first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): phone hub, lobby, picker, party panel, and invitations

Replace the phone's Watch Together screens with the Watch Party
experience on the shared ViewModels:

- Hub (profile menu, detail action, invitation): availability, Return to
  Party, Rejoin, host, and join by code or link.
- Lobby: staged title, members and lobby Ready, Start, mode and guest
  policy, invitation, suggestions and votes; only the room phase opens
  the player.
- Picker sheet for staging and suggesting.
- Party panel over the retained player (Back): members, invite, Leave,
  Return everyone to lobby, End for everyone (D2).
- In-playback status: waiting for, catching up, continuing without, host
  away, and a reconnect notice after two seconds.
- Detail pages: one party action (Watch Party, Add to party, Play for
  everyone, Suggest to Party) and the D5 leave-first guard in front of
  solo playback, home quick play, and external player links.
- silo://watch-party invitations, accepted only while the Experimental
  toggle is on; the join token stays in memory and routes carry an opaque
  id.
- Settings → Experimental → Watch Party.
- A restored party route without a live membership opens the hub.

Entry points follow WatchPartyExposure. The old watchtogether package and
its tests are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): TV hub, lobby, picker, party panel, and settings toggle

Replace the TV's Watch Together dialogs and lobby with the Watch Party
experience on the shared ViewModels:

- Hub from the profile menu: availability states with a focusable
  control, Return to Party, Rejoin, Host (Host Picks), Join with code,
  and why the last party ended.
- Lobby: staged title and edition, members and lobby Ready, Start,
  options, Leave with the host note, the code for everyone and a QR for
  the host, and suggestions. Controls are disabled rather than removed so
  live updates keep focus.
- Picker with server rows and search.
- Detail page: Watch Party, Add to party, Play for everyone (confirmed),
  or Suggest to Party, and a leave-first prompt in front of solo Play
  and extras (D5). A launcher play link during a party opens the title
  page instead.
- Back in party playback opens a party panel over the running player
  (members, code, Back to playback, Return everyone to lobby, End, Leave)
  and a status overlay that never takes focus.
- Settings → General → Experimental → Watch Party.
- Party screens already on the back stack are reused, and a restored
  party route without a live membership opens the hub.

The old watchtogether package and its tests are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(watch-party): report why the last room socket ended in the debug status

Live qualification showed room sockets ending after 17-40 s on one
emulator with nothing in the server log to explain it. The repository
now records each socket attempt's end (terminal frame, replacement, or
the transport exception class and message) with how long it was open,
and the debug receiver's party status includes it as lastSocketEnd.
It carries no tickets or tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): issues found in live phone and TV qualification

- Ending a party for everyone left a Rejoin for a room that no longer
  exists. The repository now announces rooms this device closed, and the
  recent-party recorder forgets them. Leave still keeps Rejoin, which
  the host Leave dialog promises.
- Joining an ended room showed the generic conflict text. A join 409 now
  says the party has ended.
- TV: the confirm dialog meant to focus Cancel, but the popup handed
  focus to its first row (End party). Focus now moves to the intended
  row through the observed-focus helper.
- TV: a guest's on-screen Pause did nothing and said nothing. It now
  goes to the room like the remote's play/pause key, so a refusal shows
  its notice.
- TV: the join-code keypad was translucent over the hub's text. It now
  has a scrim and a near-opaque panel, like the party's other dialogs.
- Phone: member statuses no longer shift with name length.
- Phone: finding an existing party player uses the public back-stack
  API instead of the restricted currentBackStack.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(watch-party): report the server's raw refusal reason on phone

The phone told the room why playback was refused from diagnosticsCode,
a telemetry allow-list, while the TV used the terminal reason exactly as
the server sent it. The mobile starter now records terminalReason too,
and the phone reports it, so both platforms send the same value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(watch-party): remove the Watch Together entry leftovers

Both platforms now use the shared Watch Party ViewModels and the
Experimental toggle, so these have no callers:

- CLIENT_WATCH_TOGETHER_SURFACE_ENABLED and its test (WatchPartyExposure
  replaces it; WatchPartyExperimentTest covers the per-build default).
- WatchTogetherEntryPolicy and its test (content-present entry rules).
- The WatchTogetherEntryGateway interface and its DI binding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: describe Watch Party as experimental behind the Settings toggle

D1 exposes Watch Party on phone and TV behind Settings → Experimental →
Watch Party (on in debug builds, off in release builds) and the server's
capabilities. Update the product-exposure rules, README, FEATURES (with
an experimental legend), and docs/README to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(harness): measure party spread from the fresh player position

`spread` used screenPositionSec, which the TV updates only every 500 ms,
so readings swung by up to half a second. It now uses the player position
when that is on the same timeline as the screen position, and falls back
to the screen position when a remux or transcode starts mid-title.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): stop remembering a party as it closes

The TV hub offered Rejoin for a party it had just ended for everyone.
The repository publishes an end while the last snapshot is still live,
then clears both. The recorder checked the live membership first, so it
remembered the room again, and the cleared pair never reached the forget
branch. A room-gone end now wins, and a membership whose own room just
ended is never remembered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): a dead room socket can no longer stop the playback binding

On a throttled TV, the socket died while the binding was sending to it.
Its outgoing channel was cancelled, the pending send threw
CancellationException, and the realtime client rethrew it as if the
binding had been cancelled. The binding's event loop ended silently. The
TV kept playing 30 minutes behind the room, marked as left behind, and
never caught up, even after it reconnected and new commands arrived.

A send now propagates only the caller's own cancellation; any other
failure, including a cancelled channel, is a failed send the binding
retries on the next socket. Each send is also bounded to 5 s, so the
loop never waits on a socket that has stopped draining.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): stop a phone guest from fighting the room's seeks

With a web host seeking, the phone guest seeked back and forth between
the old and new positions every ~35 ms, thousands of times, and never
became ready. A MediaController reports its own seek at once and the
session reports it again; the phone's issued-seek tracker consumed the
first match, so the second report looked like an outside seek, which a
guest undoes, which the room redoes. The TV's tracker never consumed
matches and did not loop.

Issued seeks now stay recognized until they expire, as on TV, and the
coordinator undoes at most one outside seek per two seconds, so a future
mismatch cannot turn into a loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): keep party invitations through sign-in

A silo://watch-party link that arrived before sign-in or profile
selection was pinned to the identity generation at arrival. Signing in
moves that generation, so the pending hub route failed its scope check
and was consumed without opening. Invitations now travel unscoped, like
account invitations: delivery still waits until the user is past setup,
login, and profile selection, and the hub joins only when the link's
server is the active one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): start each membership with a fresh server clock

The room clock estimator kept its samples across memberships. After
leaving one party and joining another, possibly on another server, the
new room's commands were scheduled with the old offset, and the
estimator could keep preferring the old low-RTT samples for up to ten
minutes. Installing or clearing a membership now resets and republishes
the estimate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): offer TV "Add to party" only when the server stages

The TV detail page offered "Add to party" to a Host Picks host whenever
the lobby was open, even on a server that does not advertise
staged_selection, and then called the unsupported endpoint. It now
requires the capability, as the phone's eligibility rule already did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): repair command timing and party transitions

* fix(watch-party): undo every denied outside seek on a guest

The two-second cooldown on undoing outside seeks meant a guest's second
genuine seek within that window (a double press on a headset or
notification control) stayed at a position the room had refused until a
later command corrected it. Both players now recognize their own restore
seeks, so every outside seek is undone. For a guest, only a runaway burst
of more than four within two seconds stops restoring, as a guard against
a loop; a host always restores so its report cannot re-anchor the room
early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): show only movies, series, and episodes in picker rows

The picker's Continue together and watchlist rows come from profile
catalog access, which can include ebooks and other non-video items.
Search results were already limited to playable video types, but the
rows were not, so TV could show reading items (excluded on TV) and both
platforms could offer items that cannot play in a party. The shared
picker ViewModel now applies the same filter to both rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): restore every denied external seek

* feat(watch-party): match the iOS and tvOS Watch Party design

The Android party screens were plain utility lists while Apple's are
artwork-led rooms. Phone and TV now follow the Apple layouts:

- Lobby: the staged title's backdrop fills the room, with a code pill,
  connection dot, options, a hero with facts and quality chips, member
  seats whose rings show readiness, a voting ballot or suggestion list,
  and the one action that matters pinned at the bottom.
- Back during party playback reopens that room over the player with
  "Return to playback" as the main action.
- New invitation page with a QR code of the invite link.
- Hub, options, join keypad, and the title picker (Home-style shelves
  plus a confirmation page with "Who's seen it") follow Apple too.

The lobby's wording and foregrounded action come from one shared policy
ported from Apple's WatchPartyLobbyPolicy, so phone and TV say the same
thing. Guests can mark ready in any lobby, as on Apple, and a voting
room only offers Start when the server can promote its leader. Avatar
colours use Apple's hash, so a member has one colour on every client.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): close three review gaps in party commands and entry

- A room command whose execute_at can't be read is now a malformed
  frame, so the room reconciles instead of running the command at once.
- A SiloCast launch is refused whenever the TV is in a party, including
  the lobby, where no party player is registered yet.
- The TV suggestion menu offers Queue only when the server supports
  staged selection, as the phone already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): suggest in playing vote rooms and await TV departure

- A host on a detail page in a playing voting room now gets "Suggest to
  Party" on phone and TV. "Play for everyone" sends a direct selection,
  which the server refuses in vote rooms.
- The TV's "Leave and play" waits for the party to finish leaving
  before starting solo playback, as the phone already does, so a queued
  room command can't reach the new playback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): re-check server support whenever the hub is shown

A successful capability probe was cached until the process or identity
changed, so a server that turned Watch Party off went unnoticed. The phone
and TV hubs now force a probe each time they're shown outside a party, as
Apple does. A failed probe still keeps the last known answer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): reconcile instead of running unknown room actions

A transport command with an action this client doesn't know decoded as
Unknown, and the playback binding treated it as advancing playback. The
decoder now reports it as malformed, so the room reconciles instead. The
server sends only play, pause, and seek.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): tighten room commands, host entry, and outside play/pause

- A transport command without a known playback_state is malformed and
  reconciles the room; the server always sends one.
- Phone and TV hubs hide Start on servers without staged selection and
  say the party can still be joined, instead of offering a button the
  view model then refuses. TV focus falls to Join there.
- After an outside play/pause (notification, headset, Assistant), both
  players sample the room state only once the room's play/pause is
  restored, so a report can't carry the outside change to the room.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): hide detail-page hosting on servers that can't host

The detail page still offered "Watch Party" (host with this title) on a
server without staged selection, and the hub then refused it. Phone and
TV now hide that entry once the server is known not to host, and keep it
while support is still unknown so the hub can explain the result.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): wait for the hub re-check and reuse in-flight suggestions

- The phone hub now waits for its forced capability re-check before it
  consumes an invitation or detail-page host handoff, since the previous
  answer stays published while the probe runs.
- "Suggest to Party" records its request before sending, so a second tap
  while the first is in flight resends the same suggestion id instead of
  adding a duplicate, and a later result can't clear another's retry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): fresh start per title, TV host re-check, lobby suggest reuse

- A new selection revision resets the reload budget and stall history,
  so the previous title's backoff and lower-quality offer don't carry
  over to the new stream.
- TV detail-page hosting forces a capability re-check and waits for it
  before creating a party, like the hub.
- The lobby's "Suggest" reuses the id of an uncertain or in-flight
  suggestion of the same title, and only a request's own outcome can
  clear the retry draft.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watch-party): harden room sync after review

- Return the correction rate to 1x while the room socket is detached or
  reconnecting, instead of leaving up to 1.25x running.
- Hold a Play correction that the reload budget defers and run it once the
  budget allows, rather than playing on at the wrong position.
- Count expiring ws-tickets against the reconnect budget and renew the
  proof when its expiry is unknown, so the loop cannot retry forever.
- Renew the room proof on the server clock estimate, and never re-read the
  room back to back after a successful renewal.
- Pair each pong's receipt time with the monotonic time of the same moment,
  so a queued pong no longer reads as a wall-clock jump and wipes the
  clock estimate.
- Retry a ws-ticket 401 as a transport failure instead of ending the party.
- A coalesced suggestion read now shares the read's real outcome.
- Phone retries resume where the player was within the same epoch.
- Share the issued-seek tracker, invite server match, and notice copy
  between phone and TV in android-shared.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rver#482)

* fix(tv): start and stop the cast receiver as sign-in changes

The TV checked sign-in once each time the app came to the foreground.
Signing in or picking a profile while the app stayed open never started
the SiloCast receiver, so remotes couldn't find the TV until it was
backgrounded and reopened. Signing out left the receiver running.

MainTvActivity now re-checks sign-in after every completed identity
transition, inside repeatOnLifecycle(STARTED), and calls start() or
stop() on the main thread when the answer changes. A remote-playback
temporary identity counts as signed in, so a handoff is never stopped.

The receiver's server-switch handler now ignores profile and last-used
updates to the registry entry. Re-registering on a profile clear raced
the stop() that followed, so the TV stayed listed in Bonjour after
sign-out.

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

* fix(tv): drop stale cast sign-in reads and late receiver starts

A sign-in result read on IO could reach the main thread after a newer
identity change and briefly start or stop the receiver. Each result now
carries the identity generation it was read under, and the activity
ignores it if the generation has moved on; the transition's DID_CHANGE
triggers a fresh read.

TvSiloCastReceiver.start() published its socket and advertised from an
IO job that stop() could not interrupt, so a quick sign-in then sign-out
could leave the TV listening and listed in Bonjour. The job now publishes
and advertises under the receiver lock only while its scope is current,
and closes the socket otherwise. The server-switch collector re-advertises
through refreshAdvertisement() for the same reason.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Silo-Server#477)

* feat(tv-settings): opt-in True Black Bars for grey HDR letterbox bars

On some Android TV devices, such as the Onn 4K Pro, the letterbox bars of
a Dolby Vision title narrower than 16:9 glow grey. The bar rows hold only
the player's opaque black, and the device composites that black above
reference black while Dolby Vision is on. Other devices do the opposite,
and no platform signal tells them apart.

Add an off-by-default, device-local True Black Bars toggle under Settings
> Playback > Streaming. When it is on and the first frame has rendered,
the player clears its area to transparent instead of painting it black,
so the bars come from the output's own black. Reset Playback Overrides
clears it, and it never syncs to the server.

Refs Silo-Server#475

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tv-player): keep the black plate while Navigation fades the player

The navigation fade draws the player into a layer, so clearing it there
let the other screen show through the bars. Clear only after the first
frame and once the transition has settled. Found in cross-provider review.

Refs Silo-Server#475

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ilo-Server#483)

* fix(playback): never block playback on an earlier unfinished session

Starting playback refused with "A previous playback request needs
recovery" while an earlier session for the profile was unsettled. The
settle step skipped sessions recorded under another server address,
account or installation, but the check that blocked playback did not,
so one such session blocked playback until app data was cleared.

The server allows a new session while an older one is open and expires
sessions nobody stops. Start now resends pending stops and then starts
regardless; unreachable attempts and lost starts are dropped rather
than replayed; a failed stop no longer fails the player session. The
manual "Retry Pending Playback Stops" Settings rows are removed.

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

* fix(playback): bound stop retries on the playback start path

Start retried every pending stop with three attempts each, and stop
requests can take up to 60 seconds, so a server that hung on stops
could hold each start for minutes per pending stop. Make one attempt
per stop and end the pass at the first failure; the rest wait for the
next start.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@JonahMMay
JonahMMay force-pushed the sync/upstream-2026-10-11 branch from e35e973 to 55a8b6b Compare October 11, 2026 18:21
@JonahMMay
JonahMMay force-pushed the sync/upstream-2026-10-11 branch from 55a8b6b to 0279c6c Compare October 11, 2026 18:21
…ks and class scope for accountNameOn (earlier sync mangled the tail)
…pstream, transformed upstream) preserving fork siloCamel identifiers; resolve 46 conflicts keeping Live TV/Watch Together/PrairieCast/Profile Selection/stats
…, keyboard refs, Watch Together members, settings divider, requests isEnabled/notice/actionInFlightId) and fix own-repo refs
…TV/requests surfaces) and fix TV shell/player compile breaks
…ingForCurrentViewer (fork playback-recovery surface)
@JonahMMay

Copy link
Copy Markdown
Author

Superseded by #41 (rebrand-aware merge).

@JonahMMay JonahMMay closed this Oct 12, 2026
@JonahMMay
JonahMMay deleted the sync/upstream-2026-10-11 branch October 12, 2026 17:04
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.

10 participants