Skip to content

Sync upstream silo-android (83 commits) — rebrand-aware merge - #41

Merged
JonahMMay merged 90 commits into
mainfrom
sync/upstream-2026-10-12
Oct 12, 2026
Merged

JonahMMay merged 90 commits into
mainfrom
sync/upstream-2026-10-12

Conversation

@JonahMMay

Copy link
Copy Markdown

Replaces #37. That branch had 25 "restore what the re-merge dropped" commits and red CI. This one is a fresh merge made with prairie_sync.py from Prairie-Server/.github#1: the 3-way base is rebrand(merge-base), theirs is rebrand(upstream), and the second parent is the real upstream commit 726684d. Land with "Create a merge commit".

The 35 files the tool couldn't auto-resolve were resolved by hand, keeping both sides. check-prairie-invariants.sh: all 78 hold. audit: no conflict markers.

Prairie features carried across upstream refactors

  • Live TV stays reachable: phone ProfileMenu and MainScreen, TV TvRoute and TvMainShell, next to upstream's Watch Party.
  • Upstream deleted QualitySelector (feat(player): show the server quality menu on phone and align the TV picker Silo-Server/silo-android#495, replaced by PlaybackQualitySheet). Prairie's quality ladder is now the fallback when the server plan has no quality menu, on phone and TV. Please review both paths.
  • Upstream's Settings re-layout (feat(settings): lay out Android and Android TV Settings like the Apple apps Silo-Server/silo-android#445) deleted ServerInfoSection and AccountSection. The Prairie Updates row and the "Quick Connect" row now live in SettingsScreen.kt, and the invariant anchors are re-pointed.
  • Stats-for-nerds content is rebuilt inside upstream's PlayerMenu/PlayerGroup. The ThumbhashImage format cascade is kept alongside upstream's failed flag. LAN-discovery first-run is kept.
  • Fixed compile breaks outside the conflict set: ServerListViewModel DI arity, and silo_wordmark/silo_icon_background → the prairie_* resources.

Judgement calls

  • Prairie's button and dialog icons were dropped where upstream moved to PrairieDialog/PrairieDialogAction, which have no icon slot: cast, downloads, requests, server list.
  • Two Prairie RequestsViewModel detail tests were deleted because they relied on notice/error behaviour upstream removed. Others were trimmed to the new API.
  • Follow-up: upstream's SignOutTeardown (Settings → Sign Out) doesn't reset LiveTvFeatureStore; only the profile-menu sign-out does.
  • New upstream datastore names keep the silo_* prefix (silo_title_art, silo_profile_launch, …), matching Prairie's existing persisted keys.

Not built locally (memory limits on the sync host). CI is the build gate.

🤖 Generated with Claude Code

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>
Quick104 and others added 21 commits October 8, 2026 19:42
)

A PGS subtitle sent as a .sup sidecar (the remux and transcode route) was
read from start to finish as soon as it was mounted, and every caption
image it decoded stayed in the sample queue until playback reached it.
For a feature film that is tens of MB of Java heap; a phone with a 192 MB
heap ran out of memory, or stalled at startup, as soon as the track was
selected.

The sidecar wrapper keeps re-continuing its loader so a sidecar never
gates the video. It now leaves the loader parked once its queued cues are
more than two minutes past the playhead, and continues it as playback
catches up (from the playback ticks) or after a seek. Only the PGS sidecar
sets a limit; text sidecars still load in one go.

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

* fix(tv): keep library browse sort and filters across app restarts

Android TV held a library's Browse sort and filters only in the screen's
ViewModel, so closing the app reset them to Title A-Z with no filters.
Phone, web, iOS, tvOS, and macOS all bring them back.

TV now saves them with the phone's BrowsePrefsStore, moved to
android-shared and keyed under its own `androidtv` prefix (phone keys are
unchanged). They are saved per server, profile, and library, and
restored when Browse opens. The A-Z jump and the preset sections are not
saved, matching tvOS. The TV filter panel gains the "Preserve sort &
filters" toggle the other clients have.

* fix(tv): offer Clear filters when a filtered Browse request fails

The Browse error screen replaces the Sort and Filter row, and Retry sends
the same query. A saved filter the server rejects would keep Browse
failing after every restart. When filters are active, the error screen
now offers Clear filters, which reloads Browse and overwrites the saved
state while keeping the sort.

* refactor(tv): simplify the Browse preserve toggle and save check

Pass the preserve value and its handler to the filter panel as one
nullable parameter, so a caller can't show the row with a value that
disagrees with the store, and drive the row from that value instead of a
local copy. Decide whether to save Browse state by comparing sort, order
and facets directly rather than converting both filters to the saved
form. Return early from onTabSelected before reading saved state for a
section that is already selected, and say which saved values the restore
actually drops.
…rtwork (Silo-Server#530)

* feat(artwork): show a default brand glow for missing artwork

A title with no artwork showed a flat dark tile on phone and tablet, and
a movie icon on TV whatever its type. It read as a loading bug.

Posters, covers and stills now show one default artwork when the image is
missing or fails to load: the Silo blue, pink and orange as a soft glow,
the same for every title, matching the Apple and web clients. A failed
load with a ThumbHash keeps the ThumbHash.

ThumbhashImage gets an opt-in defaultArtwork flag, so backdrops, logos,
avatars and person photos are unchanged. The TV episode rail and TvPoster
drop their own empty-URL branches and go through ThumbhashImage. The
default artwork carries the image's content description, so a card with
its caption hidden keeps an accessible name.

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

* feat(artwork): quieter default artwork with a type mark

Missing posters, covers and stills now show the brand glow at about a
third of its strength on a lighter base, with a small film, TV,
headphones or book mark for the item's type, matching the Apple and
web clients. Cards that centre their own play or book glyph over the
artwork show the glow without a mark.

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

* fix(artwork): fall back after a missing series poster or a bad ThumbHash

On an episode page where neither the season nor the series has a
poster, the tablet poster box now shows the default artwork once the
series lookup finishes instead of staying a flat tile. A ThumbHash that
fails to decode no longer blocks the default artwork.

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

* fix(artwork): resolve the tablet poster when an episode has no parent

An episode without a series ID or season number never runs the series
poster lookup, so mark the poster resolved and let the default artwork
show.

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

---------

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

On Amlogic boxes connected to TVs without DTS, the platform DTS decoder played silent. When the active HDMI sink lists compressed codecs but no DTS, DTS now goes to the bundled FFmpeg decoder, and DTS passthrough is no longer advertised to the server for that sink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silo-Server#532)

Android TV saved Browse sort and filters per library, so a mixed
library's Movies and Series tabs shared one saved state and one
"Preserve sort & filters" toggle. A genre or sort chosen under Movies
reopened under Series.

Save them per media scope: a scoped view of a mixed library uses its own
`.type-<scope>` key in BrowsePrefsStore. Unscoped libraries keep their
existing keys, so saved state from before this change still restores.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…er#447)

* fix(mobile): separate movies and series in mixed libraries

* fix(android): cancel superseded library recommendation loads

* fix(mobile): keep refilling full scoped shelves and preserve profile overrides

A full inline slice is not proof of exhaustion: Recently Added and Recently
Released report total_count = len(items) after LIMIT item_limit, so a
movie-only slice of 20 hid older series without a refill or notice. Only an
under-filled slice (items < item_limit, total_count <= items) now skips the
refill.

The catalog section source pages the stored page_sections definition, not
the profile-resolved one, so refilling a customized or profile-added shelf
replaced its override (e.g. a movie-only Random row filled with series).
Those shelves now keep their scoped inline slice and report it as
incomplete instead of paging the admin definition.

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

* fix(mobile): offer no shuffle in Movies- or Series-scoped collection views

The phone counterpart of the TV change: a shuffle request carries only the
collection kind and id, so a scoped view of a mixed library's collection
would shuffle both types.

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

* fix(mobile): offer no library shuffle in a Movies- or Series-scoped view

A shuffle request carries no media type, so shuffling a mixed library
with the Series chip selected would also play movies. Match the scoped
collection views and hide it until the request can carry the scope.

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

* fix(catalog): keep matching inline items when a scoped refill completes

A completed refill returned only what the catalog pages yielded, so a
changing shelf such as Random could drop a series it already showed
inline, or vanish. Merge matching inline items into the result with the
same content-id deduplication the incomplete paths use.

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

* fix(catalog): put matching inline items first when merging a scoped refill

Appending them after the refilled items let the trim to the shelf limit
evict them whenever a fresh Random sample alone filled the shelf. They
are the start of an ordered shelf anyway, so leading with them keeps
order and guarantees they survive.

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

* perf(catalog): stop refilling a scoped shelf once inline items complete it

The refill loop compared only the newly paged items with the shelf
target, although the result also keeps the matching inline items. A
Random shelf whose inline items nearly filled it kept paging, up to
eight requests, after one new item had already completed it. Count the
merged, deduplicated items instead.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…can (Silo-Server#488)

* feat(cast): start phone-to-TV playback without a handoff when the TV can

Pressing Play on the phone while Remote Control drives an Android TV ran
the full profile handoff every time, even when the TV was already signed
in with the same profile, and the TV ended the borrowed profile whenever a
player closed. Each title took several seconds to start, and the TV showed
nothing while it waited.

Android TV:
- Answer handoff_ready at once (reused: true) and play on the TV's own
  identity when the controller was authorized at hello against the TV's
  own server, no temporary identity is installed, and the TV is signed in
  with the offered profile (RemotePlaybackOwnIdentityPolicy).
- Keep a borrowed profile while its phone holds the slot; hand it back
  when that phone leaves, another phone takes over, or after 10 minutes
  idle. The idle limit replaces the 60 s ready-without-launch timeout.
- Unpublish an identity before its server logout, so nothing launches on
  or reuses an identity being ended; a started hand-back is no longer
  cancellable, and its logout is capped at 5 s.
- Show "Starting {title}" / "From {phone}" on the standby view while a
  full handoff runs.

Phone:
- Send the item's title in handoff_offer (optional, display-only).
- Show the title's poster and "Starting on {TV}..." from the tap until the
  TV reports that title, with tick, confirm, and reject haptics.
- Read "Connected / to {TV}" in the mini player while the TV is idle.

Closes Silo-Server#486

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

* fix(cast): close two launch races found in review

- Phone: read the outgoing title before sending the launch frame, so the
  TV's state for the new title is never recorded as the title being
  replaced, which left the remote on "Starting..." until the 30 s timeout.
- TV: a scheduled hand-back and launch admission now decide under the
  receiver lock. A hand-back commits only while nothing is playing on, or
  launching to, the identity; admission refuses an identity whose end has
  committed or that is gone, with handoff_required. The bookkeeping after
  an end now runs in its non-cancellable step.

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

* fix(tv): clear only the ended generation's pending launch

After a hand-back's logout, clear the pending launch only when it still
belongs to the ended generation, under the receiver lock that launch
admission uses. A launch admitted for a newer identity in the meantime
keeps its protection from the idle limit until its player registers.

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

* fix(tv): admit one launch at a time, from the phone that holds the TV

A launch still being handled for a phone that just lost the slot could be
admitted after the new phone's handoff began, and the new phone's handoff
could then replace the identity before that launch's player registered.

- Launch admission now runs only for the active session and only when no
  other launch is pending. Each admitted launch owns the pending marker,
  and cleanup clears the marker only for its owner.
- A handoff offer is refused with handoff_cancel reason launch_pending
  while a launch is on its way to the player.
- A pending launch expires after 30 s, so a player that never opens
  cannot hold off every later handoff.

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

* fix(tv): drop an expired or abandoned launch before navigation opens it

When a pending launch expired, or the receiver stopped, the marker was
cleared but the request could still sit in the launch channel. Navigation
would open it later, possibly under a newer phone's identity. Expiry now
drops its own request if navigation has not taken it, and stop() drops
any queued request.

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

* fix(tv): check a phone's launch is still current before its player opens

Dropping a queued request on expiry could not reach a request navigation
had already taken. That launch could still open its player after it
expired, or after its identity changed, and play under the wrong identity.

- Each admitted launch now carries an id through navigation and the
  player route. Expiry or stop() revokes the id.
- Navigation confirms the id right before it opens the player and skips
  a stale launch, releasing its pending marker so the next launch is not
  refused.
- The player registers with the id. A launch that went stale in the
  moment before registration registers nothing, and the screen closes the
  way a phone's Stop does. Only the launched player takes the launch's
  identity generation.

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

* fix(tv): tie a phone's launch to the identity it was admitted on

The launch check from b35aeac left gaps. A launch on the TV's own
profile carried no identity, so a profile switch before its player opened
went unnoticed. A recreated player skipped the check for its launch even
after the borrowed identity ended. The player screen started loading
before the check ran, and a stale player left through the generic Back,
which could close a newer launch's player.

- Each admitted launch records the IdentityTransitionBarrier generation.
  Any sign-in, profile, server or borrowed-identity change since makes it
  stale, on the TV's own profile as well as a borrowed one. Admission
  refuses such a launch with handoff_required.
- A recreated player re-registers only while its launch's identity is
  unchanged.
- Navigation checks the launch before it creates the player screen, so a
  stale launch never starts loading.
- A launch that goes stale just before registration stops what its
  player started and leaves only its own back stack entry.

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

* fix(tv): ignore unrelated server removals and recheck a stale player before stopping it

Every IdentityTransitionBarrier transition bumps its generation, including
removing a saved server that is not the active one. A launch admitted
just before such a removal was refused although its identity had not
changed.

A stale player's exit also stopped the shared service player before it
checked whether a newer launch's player had already replaced it.

- The receiver counts only WILL_CHANGE transitions that affect the
  current identity, the filter NotificationsRepository uses, and compares
  launches against that count.
- The stale-launch callback now checks that this back stack entry is
  still on top right before it stops playback and pops it. Otherwise the
  newer navigation has already replaced and torn down this player.

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

* fix(tv): read a launch's identity only while no identity change is in flight

The identity revision advances at WILL_CHANGE, before the change lands.
A launch admitted in between could take the new revision, check the old
profile, and then stay valid after the switch.

Admission now reads the identity and its revision inside
IdentityTransitionBarrier.withCurrentGeneration, which holds identity
changes off while it runs, so a change already under way is waited out
instead of half observed. The read retries if changes keep landing, then
refuses the launch with handoff_required. This is the same fence
captureAccountSessionExpectation uses.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The "Set up with your phone" screen pictured step 1, "Open Silo on your
phone", with three slanted bars on black: a hand-drawn stand-in that has
no play triangle or bar cutouts and doesn't match the app icon.

Draw the flat Silo mark from silo-branding (logo/svg/silo-mark-flat.svg,
added as the silo_mark vector) on the app icon's #010D9F field, at the
84% height the icon uses.

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

* fix(settings): let a device go back to the profile's playback settings

Android writes every playback setting as a device override, but the only
way back was "Reset Playback Overrides", which cleared everything at
once, without asking and without feedback.

Each setting the profile can also hold now offers "Use profile setting":
the first option of the Quality, Audio Language, Show Next Up and Skip
Intros pickers (checked while the device has no value of its own), and a
"Use Profile Setting" row under the Auto-Play Next Episode and Skip
Credits switches while the device holds its own value. Choosing it
deletes the device value through the same scoped queue writes use, and
clears the keys a control writes together (both quality axes; the intro
skip enum and its deprecated boolean). The store tracks which keys this
device holds, from its own writes and from the scope each refresh
reports.

Choosing no audio language now clears the device value instead of
storing an explicit null over the profile's language, so the separate
"No preference" entry is gone from the device picker.

The bulk action is now "Use Profile Settings". It asks first ("Use your
profile's settings on this device?") and shows a notice afterwards,
which says when the change is still waiting for the server. The copy
says that device-only settings go back to their defaults, which is what
the action does to them.

On TV, the option pickers now focus the current choice when they open;
before, the dialog's first row kept focus, which would now be "Use
profile setting".

* fix(settings): confirm the profile reset against the server's answer

Use Profile Settings reported success whenever the write queue came back
empty. The queue also drops deletes it cannot retry, such as a 401 or 403,
so the notice could say the profile's settings apply while the device
values were still on the server. The reset now reports success only when
the following refresh reads the server's values and none of them still
resolves from this device.

* fix(settings): confirm a device reset only for the profile it started on

resetAllDeviceSettings confirmed success from a refresh that re-read the active scope, so a profile switch or sign-out during the reset could confirm it from another profile's answer and write that answer into the wrong store. The confirming refresh now runs against the scope captured at the start, with its authority, and the reset reports false once that scope is no longer active.

* fix(settings): stop promising a reset will apply after reconnecting

"Use Profile Settings" said the profile's settings would apply when the
device reconnects whenever the server didn't confirm the reset. That only
holds while the app stays alive and offline. The write queue is in memory
and drops deletes it can't retry (a refused delete, a profile switch), so
in those cases nothing applies later. The notice now says the server
didn't confirm the change and asks the user to try again; running the
reset again is safe.

The legacy device-settings import also marks each value it writes as this
device's own, as every other device write does, so imported values offer
"Use profile setting" before the next refresh confirms them.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… scope (Silo-Server#478)

* fix(collections): open library-tab collection items without a library scope

Items in a collection opened from a library's Collections tab were read
with that library's id. The server answers 404 for an item scoped to a
library that does not hold it, so any item filed in another library
showed as unavailable. User collections often mix libraries, and admin
collections can be shown in libraries other than their primary one.

Phone and TV now open these items without a library scope, like the
personal Collections screens, the web client, and the Apple clients.
The route's library id still loads the collection itself.

* fix(collections): shuffle library-tab collections without a library scope

A collection shuffle draws from the whole collection, but both apps
started it with the tab's library, so the player asked for each pick
with library_id set and the server returned 404 for any pick filed in
another library. Start collection shuffles unscoped, like item clicks
and the Apple apps, and drop the phone view model's unused
browseLibraryId.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#527)

* fix(player): consume and reconcile recap and credits markers

* fix(player): keep markers bound to the file being played

The marker reconcile read watch detail without naming the playing file,
so the server filled on-demand markers for the default version and the
reconcile cleared the playing version's ranges. It now sends file_id.

A markers_updated event is applied only when its file_id matches the
playing file; the server also delivers a file's update to sessions that
requested that file but play another.

Recovery and subtitle replans that land on another file in the same
session now take that file's markers and reconcile them, since no new
realtime connection opens to do it. Download playback reads the
downloaded version's markers instead of the root ranges.

Also moves the applyUpdatedMarkers KDoc back onto its function and uses
imports in place of fully qualified names.

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

* fix(player): name the file and room library in marker reads

Download playback now sends the downloaded file's id with its watch
detail read, so the server fills that version's on-demand markers; local
playback has no control session to reconcile them later.

The TV marker reconcile reads watch detail in the same library playback
started from. A Watch Party can move to a title in another library while
the player stays open, and the launch library then pointed elsewhere.

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

* fix(tv): hide the marker skip pill when playback fails

The TV overlays still draw over the error screen, so a failure inside a
recap or credits range left the Skip pill visible and focusable beside
Retry. The marker target is now off while playback is loading or failed.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…othing (Silo-Server#472)

* fix(libraries): tell an empty library apart from filters that match nothing

An empty library and a filter that matched nothing showed the same kind
of message, so a viewer could not tell whether to clear filters or wait
for media. On phone the copy depended on whether sort or filters had
changed; on TV every empty browse said "No titles match the current
filters."

When a library browse comes back empty, the screen now checks whether
the library holds anything at all. With no filter or letter applied, the
empty page already answers that. Otherwise it reads one unfiltered item
without a total (CatalogRepository.libraryHasItems), straight from the
API so a one-item page never replaces the cached offline page. An empty
library says "This library is empty", as on the web; filters that hide
everything keep the filter message. If the check fails, the existing
copy stays.

This covers the phone library and browse screens and the TV library
Browse tab.

Refs Silo-Server#451

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

* fix(libraries): scope the empty-library check to the mixed-library type

In a mixed library's Movies or Series view, the one-item check asked the
whole library, so a Series view with no series reported "No matches"
once a filter was set, while the same view without a filter said "This
library is empty". Ask within the view's type on phone and TV, as Apple
does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…-Server#440)

* feat(profiles): send stale PIN profiles back to profile selection

When an administrator changes an account's access, the server stops
accepting PIN profile tokens minted before the change and answers
403 profile_verification_required. Android had no general handling,
so every screen showed a failed load, and the phone picker failed too
because it still sent the stale token.

The v2 error decoder now reports that refusal with the profile id and
token the request presented. A recovery clears the profile selection
only when that is still the active profile on the active server, never
the sign-in, and phone and TV open the profile picker. Reports for an
identity that has since changed, a remote-playback overlay, or
household-management calls (which use the same problem type for a
missing permission) are ignored, so refusals cannot loop. The prompt
waits behind playback and sign-in screens, and the picker reloads in
place when it is already open.

* fix(notifications): resume realtime after the profile is re-verified

A 401 or 403 on the events ticket stopped the notifications realtime
loop for the rest of the session. After an access change that is the
normal path for a PIN profile: the ticket is refused until the user
enters the PIN again, and the unread badge then never updated live.

The loop now waits for the next identity change (PIN re-entry, profile
switch, account or server change) and reconnects with a new ticket.
A socket the server closes with 4001 access_changed already ends
cleanly and reconnects like any other close.

* feat(realtime): refresh access-dependent screens when access changes

When an administrator changes an account's access, the events socket
sends {"type":"access_changed"} and closes with code 4001. Android
reconnected, but only Home could refetch, so libraries, search, item
details, and collections kept showing what the old policy allowed.

The events socket now reports the frame and the 4001 close once per
connection. Home, the library lists and TV tabs, search, item details,
and collections refetch through their existing quiet refresh paths on
phone and TV, coalesced so both sockets seeing the same change cause
one refresh. The sockets reconnect with a new ticket as before. A
server that never sends the signal leaves all of this idle.

* test(auth): cover the quiet refresh on token_refresh_required

After a role change the server rejects older access tokens with
401 token_refresh_required while the refresh token keeps working.
The auth plugin already refreshes and retries once on any 401; these
tests pin that for the active and the pinned background path and
check that the session and profile selection survive.

* fix(realtime): refresh once when both events sockets report one access change

The Home and notifications sockets each detect an access change on their own
server-side check, which runs every 15 seconds from each connection's start.
Their reports are usually seconds apart, so the 750 ms debounce let both
through and screens such as search refetched twice.

The first report now refreshes at once, and reports within 20 seconds of it
count as the same change.

* fix(profiles): log only the exception class when recovery init fails

* fix(detail): show the unavailable state when an access change hides the title

The access-change refresh reused the quiet return refresh, which ignored every error, so a title the viewer lost access to stayed on screen. A 403 (other than a stale profile proof), 404, or 410 now replaces the detail with the error the initial load shows; network failures and 5xx still keep the current detail.

* fix(profiles): re-check a deferred profile prompt when remote playback ends

A prompt deferred behind a remote-playback overlay was only decided again on the next navigation. The recovery now re-emits the pending prompt after each completed identity transition, and both navigation hosts collect that, so ending the overlay sends the user to profile selection without another navigation.

* fix(tv): refresh library pages when the viewer's access changes

TvLibraryDetailViewModel kept its one-time loaded flags across an access change, so recommended sections, browse results, audiobook groups, facets, and collections stayed stale. The screen now reloads the visible tab and marks the rest for reload when next opened.

* fix(tv): re-probe library collections after an access change

The collections probe was keyed only on the library list, which can stay equal while collection visibility changes, so the Collections pill kept its old answer.

* fix(detail): reload a refused title when access changes again

After an access change refused a title, the detail was cleared, and the next
access-change refresh returned early because there was no detail to refresh.
A title the viewer regained access to stayed on the error until they tapped
Retry. With no detail on screen, an access change now runs the full load, as
Retry does.

* fix(detail): keep a refused title hidden when the full load runs again

The full detail load paints the durable cached copy before the live request
returns, and a refusal does not evict that cache. When the live request was
refused, the error was set but the cached detail stayed, and the screen shows
detail ahead of the error. After an access change refused a title, the next
access change, or Retry, brought the refused title back with all its actions.

On phone and TV, a refusal (404, 410, or a 403 other than
profile_verification_required) during the full load now clears the cached
detail, so the screen shows the error. Network errors and other failures keep
it as before.

Adds phone and TV tests for both access-change paths: a title the viewer
regains comes back, and a title still refused stays on the error.

* fix(realtime): apply access changes a screen missed while away

Screens refreshed for an access change only while composed, so a retained
ViewModel whose screen was behind another destination (TV library behind a
detail page, detail behind the player, phone Libraries while another tab
showed) kept the old policy's data when the user came back. Each of those
ViewModels now keeps an AccessChangeCursor with the last revision it
applied, and its screen refreshes on return for a change reported while it
was away, under the same coalescing window.

Related gaps from review:
- A detail page whose first load was still in flight ignored an access
  change, so the old policy's answer stayed. The change now replaces the
  unfinished load on phone and TV.
- Phone Libraries reloaded only the visible subtab; it now forgets which
  subtabs and facets were loaded, as TV does.
- Home dropped a realtime signal that arrived during a running fetch; it
  now runs one follow-up refresh after it.
- After profile recovery cleared the selection, Back on the phone picker
  returned to Main with no profile. Back now leaves the app instead.
- A refusal no longer clears a phone detail page whose title has a
  completed download, so its local play and delete actions stay.
- The TV collections probe rethrows cancellation instead of publishing a
  partial set when an access change restarts it.

* fix(detail): reload past a pre-change warm-up after an access change

Replacing an in-flight detail load did not help when the load had joined
a Home warm-up for the same title: the new load joined the same request,
answered under the old policy. Detail reloads and quiet refreshes after an
access change now send their own request on phone and TV.

The phone Libraries hub and library collections screen also let an older
library-list or collections load land after the refresh an access change
triggered. A newer load now cancels the unfinished one.

* fix(tv): skip a pre-change warm-up when an access change reruns the full detail load

* fix(realtime): check the library list for an access change after the app returns to the foreground

The events sockets close while the app is in the background, and the server
compares each new connection with the policy its own ticket was minted under,
so a change made in that time was never reported. Returning to the foreground
now fetches the library list and reports an access change when its set of
libraries differs from the cached list the screens were built from.

* fix(realtime): refresh for a change reported by a socket opened after the last refresh

Access-change reports within 20 seconds of a refresh were all treated as
the second socket's report of the same change. A socket that reconnects
after reporting compares against its new ticket, so its report inside
that window is a second edit, and it was dropped for good. Reports now
carry the time their connection opened, and a report from a connection
opened after the last refresh refreshes again.

* fix(detail): keep an older quiet refresh from overwriting a newer one

A return or watched-change refresh still in flight when an access change
refreshes the page could land last and restore a title the newer read
found refused. Each quiet detail read now replaces the unfinished one,
and keeps its access-change refusal handling and, on TV, the player's
saved position.

* fix(tv): let only the newest library-list, filters, and collections load publish

An access change reloads search media types and library facets and
collections while an earlier load may still be running. The newer load
now cancels the older one, so the pre-change answer cannot land after it.

* fix(tv): keep a watched change from restoring an older resume position

A watched-change refresh that replaces an unfinished playback-return
refresh no longer re-applies that return's saved position, which the
watched write has cleared. Search also declares its library-load job
before init, which starts the first load, and a test covers a reload
after an access change superseding that first load.

* fix(profiles): return to the server list from a picker opened while adding a server

The picker's Back handler closed the app whenever no profile was selected and another screen sat below it. Adding a server from the server list leaves that list below the picker with no profile yet, so Back closed the app instead of returning to it. Close the app only when the screen below belongs to the signed-in shell.

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

* fix(search): keep an older search from overwriting the access-change re-run

An access change re-runs the current query while the earlier request for the same query can still be in flight. Both published, so a pre-change answer that finished last restored titles the viewer had lost. Each new search now takes a generation, and answers from older ones are dropped.

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

* fix(collections): let only the newest collections load publish

Collections refreshes on an access change, but the initial load or an earlier refresh could finish later and restore collections the viewer can no longer open, on phone and TV. Loads now share one job; a new load cancels the previous one.

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

* fix(libraries): keep a pending facet load from refilling facets after an access change

refreshAfterAccessChange() cleared the Browse facets but left the query generation alone, so a facet load still in flight stored the old policy's facets, and Browse then skipped fetching them.

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

* fix(tv): declare the library page's load jobs before init

init starts the first Recommended load, but recommendedGeneration and recommendedJob were declared below it, so their initializers ran afterward: the job was dropped and the generation reset to 0. An access-change reload then could not cancel the first load and reused its generation, so both answers published.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…lo-Server#427)

* feat(settings): hide spoilers for unwatched episodes, per profile

Adopt the settings contract at revision 15, which adds the profile settings
catalog.hide_unwatched_episode_images and
catalog.hide_unwatched_episode_overviews. When a profile turns them on, the
phone and TV apps blur the still and hide the description of episodes the
profile has not started: episode rails, the series hero, episode detail,
Continue Watching / Next Up cards, the TV home marquee, and the players'
next-episode screens. A Spoilers group in Settings holds the two switches,
shown only when the server serves revision 15.

Below API 31, where Modifier.blur does nothing, a hidden still shows only its
ThumbHash.

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

* fix(settings): reload spoiler settings when the server or profile changes

The spoiler store remembered only that it had loaded, not for which server
and profile. After Add Server and sign-in, it kept the previous server's
answer until the app restarted. It now follows the active server and profile
and ties each load to them.

The series hero also no longer reserves an empty block where a hidden episode
description would have been.

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

* fix(settings): preserve episode spoiler protection across updates

Cancel obsolete hydration, preserve pending toggles, and cache writes for their original profile. Rebuild TV marquee projections when spoiler preferences change and protect episode detail fallback artwork.

Co-Authored-By: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(tv): refresh marquee spoilers after watch updates

Refresh displayed and pending marquee sources from their own current rows while preserving focus and enrichment. Cover watch changes and duplicate episode IDs across rows.

Co-Authored-By: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(spoilers): preserve series artwork with v2 provenance

Carry selected image provenance through phone and TV cards, episode
rails, marquee projections, detail fallbacks, and player next-up. Keep
series artwork visible and protect older responses conservatively.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(settings): require revision 16 for spoiler keys

Preserve revision 15 for the watchlist settings already published on server main. Refresh generated bindings and fixture records from the integrated server contract.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(downloads): protect unwatched episode artwork

Persist artwork provenance and episode user data, protect inherited headers, and apply current local watch and resume state.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(tv): publish safe artwork for protected Watch Next episodes

Resolve profile preferences in background sync, use safe series artwork, and fence obsolete publication. Clear and reseed tiles when image protection changes.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(downloads): refresh episode watch state before artwork projection

Use authenticated server snapshots while preserving pending and newer local writes. Fence concurrent section builds so older snapshots cannot overwrite a refresh or deletion.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix(tv): preserve Watch Next tiles on ordinary startup

Wait for known preferences and persist the last image-protection value. Clear only when protection becomes enabled; verify that unchanged settings preserve the publication generation across seeder recreation.

Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>

* fix: track watch and playback mutations separately

* fix: protect episode artwork in catalog grids

* fix: clear catalog progress on unwatched resets

* fix(settings): treat spoiler keys the server refuses as unsupported

A server can reach the gating settings revision without these keys:
server main took revision 16 for another setting. Its batched effective
read then answers 404 unknown_setting, which the store kept as a
transient error, so support stayed Unknown, the Spoilers settings stayed
hidden, and the TV Watch Next worker retried forever. A 404 now means
unsupported, as it already does for the capabilities call.

Also point stale "revision 15" comments at the gating constant.

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

* fix(downloads): read episode watch state only for image protection

- Skip the per-episode watch reads and the Room overlay unless image
  protection is on, fetch when it turns on, and let pull-to-refresh
  reconcile download records without waiting for those reads.
- Wait for the first load instead of publishing an empty list, which
  flashed the empty state when the tab opened.
- Build sections one at a time, so a build started from a sidecar map
  that a reload is replacing can't win publication.
- Season and series rows name the download their poster came from, so a
  season showing another season's still is still protected.

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

* fix(tv): keep Watch Next safe through failed lookups and interrupted wipes

A failed series lookup was treated as "no safe artwork", so the run
omitted the protected tile and deleted it from the launcher. It now fails
the run so the worker retries.

Enabling image protection recorded the setting before the tile wipe
finished; a process death in between skipped the wipe on the next start.
The setting is now recorded after the wipe completes.

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

* fix: keep resume guards for rows written before positionUpdatedAtMs

The migration adds positionUpdatedAtMs without a backfill. A delayed
watched acknowledgement treated those rows as older and could clear a
newer resume sample, and a rejected watched write queued before the
upgrade could not restore the resume point it cleared. Both guards now
fall back to clientUpdatedAtMs for such rows.

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

* fix(mobile): reset episode progress when marking it watched or unwatched

The detail page changed only the played flag, so an in-progress episode
marked watched and then unwatched kept its old progress, counted as
started, and showed its still despite image protection. Both changes now
clear progress, as the server does and the TV app already did, and a
failed write restores the exact previous state.

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

* fix(tv): rebuild the hero backdrop when the item's watch state changes

Keyed on the content id, the hero kept the spoiler decision it made when
the item first appeared.

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

* chore(settings): gate spoiler settings on contract revision 17

Server #1709 now builds on server main, where revision 16 belongs to
other settings, and adds the two spoiler keys in revision 17. Raise the
gate to 17, regenerate SettingKeys, and re-vendor the settings and API v2
fixtures from silo-server 51953b6c1.

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

* fix(player): keep shuffled movie overviews visible with spoiler protection

Shuffle post-roll built its next-item state without the unwatched flag,
so the default (unwatched) hid every shuffled movie's overview when
episode descriptions were hidden. A shuffle pick carries no watch state,
so episodes stay protected and movies never are, on phone and TV.

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

* fix(spoilers): honor artwork provenance and close review races

- Detail heroes (phone compact and expanded, TV) and the TV marquee's
  enrichment backdrop now follow the displayed artwork's
  backdrop_is_episode_still flag instead of assuming every detail
  backdrop is series art.
- A Watch Next wipe that finishes after a newer protection change no
  longer records its older hide_images value.
- A server or profile switch shows the new identity's cached answer
  right away, and a superseded refresh releases its lock before its
  second request.
- A failed watched toggle undoes only the fields it changed, and only
  while they still show, so a newer reload survives.
- Shuffle post-roll uses the pick's own watch state.

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

* fix(detail): undo a failed watched toggle even after new progress

A return refresh can overlay a newer playback position on the optimistic
watched state. The rollback then skipped the whole snapshot and left the
episode marked watched after the write failed. Restore played while it
still shows the attempted value, and restore progress only while it
still shows the reset.

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

* fix(settings): pin spoiler writes to the profile that made them

A toggle queued behind another write was sent with whatever auth scope
was current when the request ran, so a server or profile switch in
between wrote the old choice to the new profile. Capture the auth scope
with the store's identity and pass it to every write.

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

* fix(settings): pin the spoiler value read to the captured profile

The refresh resolved both keys against whatever profile was active when
the request ran, while its answer is cached and written back under the
identity captured at the start. Pass the same captured auth scope to
the effective-values read.

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

* fix(watch-party): apply spoiler protection to party previews

The picker's confirmation page, the staged title in the lobby and the
player panel, the invite page, and suggestion posters showed an
unwatched episode's still and description even with protection on.
They now follow the same rule as every other surface, from this
profile's watch state and each artwork slot's provenance, on phone and
TV. A title known only by type counts as an unstarted episode, as a
suggestion does. The staged detail is read again when the room changes
phase, so a title the party has played stops being hidden.

Matches the Watch Party coverage in Silo-Server/silo-apple#559.

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

* fix(watch-party): keep protected stills hidden while artwork changes

The phone room backdrop crossfaded on the URL alone, so an outgoing
protected still was drawn with the incoming artwork's treatment for the
length of the fade. Each backdrop now carries its own thumbhash, poster
blur, and hidden state through the crossfade.

The TV invite page opened from the player left the staged backdrop
unprotected. It now receives the spoiler decision, which every caller
must pass, and that overlay re-reads the staged detail on phase changes
like the lobby.

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

* fix(watch-party): hide the current backdrop at once when protection turns on

With each crossfade layer keeping its own hidden state, turning
protection on for an unchanged artwork faded the sharp still out over
400 ms. A layer showing the current artwork now takes the current
protection immediately; a different outgoing artwork keeps its own.

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

* fix(spoilers): close three remaining protection gaps

- Home and the TV detail page now clear resume progress when an episode
  is marked unwatched, as the server does. Before, the episode still
  counted as started and its still stayed visible until a refresh.
- The phone Watch Party picker's shelf and search tiles hide an
  unwatched episode's still, not only the confirmation page.
- A forced profile-verification recovery clears the spoiler store on
  phone and TV, like Switch Profile, so the next profile never starts
  from the previous profile's answer.

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

* fix(spoilers): keep cast identities off the launcher and report failed saves

- While a phone's cast identity is the TV's temporary scope, the
  launcher effect no longer seeds Watch Next and the sync worker skips,
  so the phone profile's titles never replace the TV owner's row. The
  TV profile seeds again when the cast ends.
- The TV marquee and root backdrop crossfades show the current item's
  protected copy at once when protection turns on for the focused
  episode, instead of fading the sharp still and synopsis out.
- A failed spoiler-switch save now explains itself in Settings on phone
  and TV, as title art and skip intervals do, instead of silently
  snapping back.

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

* fix(tv): apply a protection change to departing marquee layers

A protection change during a crossfade between two items left the
outgoing item's sharp still and synopsis fading out. Each departing
layer is now re-derived from its source item when the current
preferences change what it shows, and left as is otherwise, so
enrichment crossfades are unaffected.

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

* fix(tv): keep safe enriched artwork when protecting departing layers

underPrefs rebuilt an episode with no description on every call, since
it compared the overview switch rather than whether a description was
hidden. On the root backdrop that dropped the enriched series art.
Protection turning on also rebuilt from the section item, losing an
explicit series backdrop. Compare what is actually hidden, drop only
what the preferences now hide (using the shown backdrop's provenance),
rebuild only to reveal, and apply it only to departing layers.

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

* fix(tv): fence Watch Next writes and picker spoilers to this profile

- The Watch Next sync now treats a phone's temporary cast identity as
  an invalid owner at every authority check, including the final write,
  so a handoff that starts mid-run cannot publish the phone profile's
  titles to the TV launcher.
- The TV Watch Party picker decides spoiler protection from this
  profile's own watch state. Its shelves carry the room's progress for
  the progress bar, which let another member's progress reveal a still
  and left this profile's own started search results blurred.

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

* fix(tv): use this profile's watch state for poster cards too

The party picker's own-progress override reached the backdrop and
reference-shelf cards but not the default poster card, which still read
the room's progress. Each row item now computes one unwatched decision
and derives both the still and the poster protection from it.

The Watch Next authority fence also runs the suspending identity lookup
before validating the captured owner, so the owner is checked last.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>
…-Server#541)

* feat(tv): add artwork and an On Deck row to the Up Next overlay

The TV Up Next overlay was a plain black screen with a text panel, while
tvOS shows the next episode's artwork behind it and an On Deck row of other
items in progress underneath. Android TV now does both:

- The next episode's still (else the first On Deck item's art) fills the
  screen, blurred and dimmed. Modifier.blur does nothing before Android 12,
  so the blur comes from a tiny decode under its own cache key. The
  mini-player pane is cleared from the layer so the video stays visible.
- On Deck reuses the phone's projection of home continue-watching
  sections. Picking an item replaces the player with one for that item,
  which resumes from its saved position; the current session finishes as
  any exit does. Hidden in a Watch Party.
- The overlay is one fixed screen. The video is a separate surface placed
  at the pane's bounds and cannot follow a scroll, so the panel is
  tightened (runtime on the episode line, two-line synopsis, Keep Watching
  beside Back) and On Deck sits along the bottom, five cards wide.
- The row uses TvSmoothBringIntoViewSpec so focus arriving from the panel
  does not slide it.

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

* fix(tv): resume On Deck picks from the saved position and redraw the cut-out

The On Deck snapshot is taken when the current item starts, so passing its
position as a resume override could rewind an item watched elsewhere since.
The pick now opens without an override and the player reads the saved
position.

The artwork cut-out read pane coordinates from state, but layout hands back
the same coordinates object each time, so a moved pane never redrew it. The
pane's bounds are now held in state.

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

* fix(tv): route On Deck picks by item type and let the panel scroll

On Deck dropped each section item's type and always opened the video
player, so an audiobook in progress would open in the wrong player. The
pick now carries the type through tvPlayDestinationFor, as the home rows do.

The fixed Up Next layout squeezed the panel's last controls when its
content outgrew the space above On Deck (large font sizes, a shuffle's
extra rows). The panel now scrolls on its own; the video pane beside it
stays put. TvSmoothBringIntoViewSpec keeps it from scrolling on every
focus move.

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

* fix(tv): keep items TV hides out of On Deck

The On Deck projection read raw home sections, so a book or other item
TV hides could appear on the Up Next overlay. It now applies visibleOnTv
first, as TV Home does.

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

* fix(tv): keep the On Deck row through a startup replan

A replan swaps the session ID for the same item without reloading On
Deck, so keying the load on the session discarded the row. The
generation and content checks already reject stale loads.

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

* fix(tv): keep safe next-episode art under spoiler protection

Carry still_is_episode_still into the next-episode state so the Up Next
backdrop shows artwork the server marks as not an episode still.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…TV Home on view (Silo-Server#536)

* feat(metadata): mark AI-translated descriptions and translate episodes and TV marquee on view

Decode machine_translated_fields on item detail, season and episode rows and
section/catalog cards, plus pending_translation_language on episode rows and
cards. Show "Translated by AI" under machine-translated overviews on phone and
TV detail pages, a compact marker on episode rows, and an icon in the TV focus
marquee.

The series page starts the shown season's translation job in auto mode when
an episode row reports a missing description, and season pages refresh their
episode list while the job runs. The TV focus marquee translates the item the
viewer rests on in auto mode. Changing the profile's metadata language
re-reads Home, For You and library lists.

* fix(tv): translate on view only for Featured cards in the focus marquee

The web client translates only the visible Featured slide. Cards resting in ordinary rows now keep their text and AI marker but no longer start translation jobs.

* fix(tv): keep a marquee translation after leaving Home

The marquee kept the translated description only in its own memory, so returning from a detail page showed the original text again. Re-read the Home rows when a translation lands.

* fix(metadata): retry a season's translation after a switch, and mark only AI overviews

Switching seasons while another season's translation was polling latched the
new season as already translated, so its episodes stayed untranslated for the
session on phone and TV. The new season's job now starts once the running one
ends.

The "Translated by AI" mark sits with the overview, but it showed for any
machine-translated field, so an AI tagline alone marked a provider or manual
overview as AI. It now requires the overview to be machine-translated.

* fix: preserve translation refresh and episode state

* fix: preserve translation retry and episode ownership

* fix: invalidate translated detail caches on language changes

* fix: bind language cache invalidation to its profile

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Silo-Server#542)

* feat(tv): ask who's watching on a new Android TV and offer to remember

A new Android TV now starts with Profile Selection set to Every Time. After
the first profile is picked, Silo asks once whether to open that profile
automatically: Remember switches to Automatic, Ask every time keeps asking.
A TV that already remembers a profile keeps Automatic.

* fix(tv): keep remembered profiles on other servers and drop stale remember offers

The first-launch settle looked only at the active server's profile slot, so
a TV whose active server had no profile selected but another saved server
did was treated as new: it saved Every Time and cleared that server's
profile on the next switch. Count a profile saved on any server.

The remember offer could outlive its profile. If profile-verification
recovery cleared the profile, or the grid's identity changed, while the
dialog was open, either answer still saved and opened Home with no active
profile. Drop the offer on those reloads, and save or navigate only while
the offered profile is still the active one.

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

* fix(tv): keep a fresh remember offer and settle only from a readable registry

Clearing the offer whenever a grid reload found the identity changed also
erased the offer from a pick committed while that reload was in flight,
since the commit itself moves the identity. The viewer was left on an
empty picker with the profile committed. Drop the offer only when its
profile is no longer the active one.

An unreadable server registry reports no servers, so the first-launch
settle took an existing TV for a new one and saved Every Time for good.
Skip the settle until the registry loads. Mark the launch resolved only
after settling, so an Activity recreated mid-settle still settles.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#551)

Read and decode Home responses and the Room Home cache off the main thread, show rows before writing the cache, let Home's first load join the startup warmup's read when no membership change has been acknowledged, and skip the TV resume refresh while the first load runs.

Closes Silo-Server#549

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 upstream commits since eb1e2ed. Rebrand-aware merge by
Prairie-Server/.github upstream-sync/prairie_sync.py: the 3-way base is
rebrand(merge-base), theirs is rebrand(upstream), and the second parent is
the real upstream commit. The tool auto-resolved the brand/format-only
hunks. The remaining 35 files were resolved by hand, keeping both sides:

- Live TV stays reachable: phone ProfileMenu/MainScreen, TV TvRoute and
  TvMainShell, alongside upstream's Watch Party.
- Upstream removed QualitySelector (replaced by PlaybackQualitySheet).
  Prairie's quality ladder is now the fallback when the server plan has
  no quality menu, on both phone and TV.
- Upstream's Settings re-layout removed ServerInfoSection and
  AccountSection. Prairie's Updates row and the Quick Connect row now live
  in SettingsScreen, and the invariant anchors are re-pointed.
- Stats-for-nerds content is rebuilt inside upstream's PlayerMenu
  layout. The ThumbhashImage format cascade is kept with upstream's
  failed flag. LAN-discovery first-run is kept on phone and TV.
- Fixed the DI arity for ServerListViewModel, and the silo_wordmark
  references to prairie_wordmark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream's new marquee referenced R.color.silo_icon_background, which only
exists as prairie_icon_background in Prairie.

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

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Walkthrough

Warning

Review details and warnings were omitted to fit the comment limit.

Upstream ca53cb0 (Silo-Server#381) retired the command; the test kept asserting it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JonahMMay and others added 4 commits October 12, 2026 17:10
The merge left upstream's new call sites rebranded (toPrairieCastVideoFillMode,
toPrairieCastSubtitlePosition) while the private helpers kept their Silo names,
so the TV app would not compile. Name them all Prairie.

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

Upstream's player restyle dropped the HudBody* constants and the tv
MaterialTheme import that Prairie's playback stats block still uses.
The collections load-order test leaked an in-flight viewModelScope load past
resetMain, surfacing as UncaughtExceptionsBeforeTest in the next test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream dropped Kover, so the 83-commit merge brought untested code that
took :shared to 94.4%. Add tests for the pure Watch Party presentation,
playback-context and request-target-summary logic, and exclude live I/O
glue (OkHttp, websocket frame helpers, server identity probe) like the
other realtime clients already are.

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

cancel() alone let the in-flight Ktor call resume on a reset Main, which
surfaced as UncaughtExceptionsBeforeTest in LiveTvViewModelTest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JonahMMay
JonahMMay merged commit 443f86f into main Oct 12, 2026
10 of 11 checks passed
@JonahMMay
JonahMMay deleted the sync/upstream-2026-10-12 branch October 12, 2026 18:51
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