Skip to content

feat(tv): skip-intro countdown as shrinking-fill button - #210

Closed
evulhotdog wants to merge 22 commits into
Silo-Server:mainfrom
evulhotdog:tv-skip-intro-shrinking-fill
Closed

feat(tv): skip-intro countdown as shrinking-fill button#210
evulhotdog wants to merge 22 commits into
Silo-Server:mainfrom
evulhotdog:tv-skip-intro-shrinking-fill

Conversation

@evulhotdog

@evulhotdog evulhotdog commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Skip Intro (Android TV)

Improves on existing Skip Intro button, and handles inputs of all kinds in a more graceful way.

What the prompt does now

The countdown prompt is a pill in the lower-right corner whose background fill creeps left to right as the timer runs, reaching full at the exact moment the auto-skip fires. It sits above the transport cluster while controls are visible and drops toward the corner when they fade out, returning when they come back.

Every input has a defined outcome:

Input Behavior
Select / OK Skips the intro immediately
Any D-pad direction Stops the timer, leaves the button in place as a solid manual "Skip Intro" pill
Back Same as a D-pad nudge: stops the timer, keeps you in the video. The press is consumed, so it doesn't also exit playback; a second Back behaves normally
No input Auto-skips when the fill completes

The button takes focus the moment it appears, so a single Select press skips, with no navigate-then-press. It renders dimmed when unfocused and lit when focused, and disappears instantly on activation rather than shrinking away.

The countdown starts when the video actually starts playing, not when the player begins coming up, so the bar and the timer always start together. A brief rebuffer no longer resets the countdown.

Notable implementation detail

The fill is driven by the frame clock rather than a Compose AnimationSpec. Compose scales animation durations by the device's animator_duration_scale (MotionDurationScale), and on a Shield set to 0.5x a 5s tween completed in 2508ms, so the bar hit full 2.5 seconds before the skip actually fired. A countdown to an automatic action has to report real time, so it deliberately ignores that setting. Decorative motion (fades, the reposition when controls hide) still honors it.

Select, D-pad, and Back are handled in the player screen's root key handler rather than on the button, because the banner is not reliably in the focus tree; key modifiers attached to it never fired.

Scope

Android TV only. The shared IntroAutoSkipController gains a playbackActive input that defaults to always-active, so the phone app's behavior is unchanged.

Tests

  • IntroAutoSkipControllerTest: countdown held until playback is active, pause stops it and resume restarts from full, cancel is per-intro, cancel outside an active countdown leaves state alone.
  • SettlingFalseEdgesTest: rebuffer debounce passes true through immediately, swallows a short stall, and surfaces a real pause after the grace period.

Behavior examples

Summary by CodeRabbit

  • New Features

    • Intro auto-skip now uses a progress-filled skip button with smoother animated transitions.
    • Countdown pauses while playback is inactive and resumes when playback continues.
    • Remote controls can cancel the countdown or skip the intro based on the current prompt state.
    • Improved focus handling, styling, and button sizing for TV navigation.
  • Bug Fixes

    • Prevented brief buffering interruptions from resetting the countdown.
    • Prevented countdown actions from firing while playback is paused or loading.
    • Improved cancellation behavior after countdown completion.

Design variant: the Skip Intro button's translucent background fill
drains left-to-right and empties as auto-skip fires. Auto-focuses so
D-pad Select skips immediately. Drops the Cancel affordance for this
variant; shared IntroAutoSkipController untouched (fill animates
between whole-second ticks).
Two bugs: the fill layer's fillMaxWidth/Height grabbed the screen's max
constraints, ballooning the pill across the screen (now sized to the
pill via matchParentSize); and AnimatedContent treated each per-second
CountingDown tick as a new target, recreating the subtree and restarting
the drain every second (now keyed on the state kind, with the fill
driven by one continuous Animatable over the full countdown).
- Fill grows left-to-right (was draining right-to-left) and lands full
  exactly when the auto-skip fires; duration comes from the countdown's
  own remaining seconds at entry, not the configured total.
- Slightly larger pill (18sp, 32/18dp padding).
- Focus request now waits two frames so it doesn't fail silently; the
  button reliably has focus on popup.
- Back dismisses the banner for this intro via new controller dismiss()
  (stays hidden; no manual pill fallback).
- Moving focus off the button cancels the timer and leaves the solid
  manual pill in place; cancelCountdown no longer resurrects the banner
  after a dismiss or a completed fire.
The focus-loss watcher inferred 'user moved off' from any focus change,
but focus here is transient (the player re-focuses its own overlay), so
it cancelled the countdown a beat after it appeared: the fill vanished
into the solid manual pill (reading as an instantly-full bar) and the
auto-skip never fired. Cancel now triggers on an actual D-pad direction
key press, which is what the requirement meant.

The manual pill also never took focus, so Select needed a navigate press
first. It now auto-focuses when it appears fresh, but not when it appears
because a countdown was cancelled (that would fight the user's move).
Countdown variant of the skip-intro prompt: a dark pill in the lower
right whose translucent fill creeps left-to-right and lands full exactly
as the auto-skip fires.

- Fill is driven by the frame clock, not an AnimationSpec. Compose scales
  spec durations by the device's animator_duration_scale
  (MotionDurationScale); measured on a Shield at 0.5x, a 5s tween
  finished in 2508ms and the bar sat full for the last 2.5s. A countdown
  to an automatic action must report real time, so it ignores that
  setting while the decorative transitions still honor it.
- Countdown is gated on playback actually running, so the prompt and the
  timer start together instead of the prompt appearing partway into an
  already-elapsed timer.
- Select, D-pad cancel, and Back dismiss are handled in the player
  screen's root key handler: the banner is not reliably in the focus
  tree, so key modifiers on the button never fired.
- Back dismisses the prompt for the current intro via a new controller
  dismiss(); a D-pad direction stops the timer and leaves the solid
  manual pill in place.
- Buttons use a single focus target (clickable owns it); the previous
  focusable+clickable pair split focus and key handling across two nodes,
  which required two Select presses to activate.
- Prompt drops toward the corner when the transport controls fade out and
  lifts back when they return.
- Unfocused state is dimmed rather than lit.
# Conflicts:
#	androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt
- Back during the countdown consumes both key phases. Consuming only
  ACTION_DOWN leaked the ACTION_UP to the activity back dispatcher, where
  whichever BackHandler happened to be topmost would also fire. Matches
  the sibling BACK handling already in this key handler.
- Debounce the false edge of playbackActive: isPlaying dips on every
  ExoPlayer rebuffer, and letting that through cancelled the countdown
  and restarted it from full mid-intro. A real pause still lands after
  the grace period.
- Share one countdown constant (IntroAutoSkipController
  .DEFAULT_COUNTDOWN_SECONDS) between the controller and the banner so
  the timer and the fill cannot drift apart.
- Cover the new shared state machine with focused tests: dismiss stays
  hidden inside the same intro, cancel-after-dismiss does not resurrect
  the pill, dismiss is per-intro, the countdown is held until playback is
  active, pause stops it and resume restarts from full, and cancel
  outside an active countdown leaves state alone.
- Drop dead plumbing the root key handler made redundant: the banner's
  onCancelCountdown param and BackHandler, the overlay's
  onCancelIntroAutoSkip param, and two unused imports. Fix KDoc that
  claimed Back was handled in the banner.
… edge

settlingFalseEdges used a flow {} builder with collectLatest, which runs
each value's block in a child coroutine; emitting into the enclosing
collector from there violates the flow invariant and threw
IllegalStateException the first time isPlaying reported false, i.e. on
every playback start.

Switched to channelFlow (the mitigation the exception itself names) and
hoisted the operator to an internal top-level function so it can be
tested. Added SettlingFalseEdgesTest covering the three contracts: true
passes straight through, a sub-grace false blip never surfaces, and a
false held past the grace period does.
Back now behaves like a D-pad nudge: it stops the timer and leaves the
solid manual Skip Intro pill in place. The press is still consumed so it
cannot also exit playback, and because the state is no longer
CountingDown afterwards, a second Back behaves normally.

That leaves nothing calling the controller's dismiss(), so the whole
dismissed-key path goes with it: dismiss(), dismissedKeys, the Hidden
branch in handle(), the dismissed check in cancelCountdown(), the
ViewModel and overlay/banner plumbing, and the two dismiss-only tests.
The per-intro test is retargeted at cancel, which is the behavior that
now exists.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aab6932a-3ad2-4989-9116-2e490c7e8193

📥 Commits

Reviewing files that changed from the base of the PR and between 1f78d06 and a7dacdc.

📒 Files selected for processing (1)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt

📝 Walkthrough

Walkthrough

The intro auto-skip flow now stabilizes playback activity, pauses countdowns during inactive playback, uses a fill-progress skip button, supports remote-key cancellation and skipping, and animates banner positioning. Tests cover these state transitions.

Changes

Intro auto-skip behavior

Layer / File(s) Summary
Stabilized playback activity
shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/SettlingFalseEdges.kt, shared/src/commonTest/kotlin/org/siloserver/silo/domain/player/SettlingFalseEdgesTest.kt, androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
Playback activity uses isPlaying && !isLoading. Brief inactive states are delayed by a 1.5-second grace period.
Playback-aware countdown controller
shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipController.kt, shared/src/commonTest/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipControllerTest.kt
The controller receives playback activity, cancels countdown jobs centrally, restarts countdowns after playback resumes, and tracks countdown runs. Tests cover key-scoped cancellation, pause/resume, expiry, and post-completion cancellation.
TV auto-skip controls and layout
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt, androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
The banner uses slot-based transitions, frame-clock progress, focus-aware buttons, and shared skip behavior. The player updates remote-key handling and animates the banner inset. User-facing cancellation wiring was removed.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TvPlayerViewModel
  participant IntroAutoSkipController
  participant TvPlayerScreen
  participant TvIntroAutoSkipBanner
  TvPlayerViewModel->>IntroAutoSkipController: provide stabilized playbackActive
  IntroAutoSkipController->>TvPlayerScreen: expose auto-skip state
  TvPlayerScreen->>TvIntroAutoSkipBanner: render countdown or skip button
  TvPlayerScreen->>IntroAutoSkipController: cancel on remote navigation
  TvIntroAutoSkipBanner->>TvPlayerScreen: request immediate skip
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main Android TV change: a shrinking-fill Skip Intro countdown button.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt`:
- Around line 2113-2116: Update the playbackActive flow in TvPlayerViewModel to
include UiState.isPaused when determining inactivity, so explicit pauses emit
false immediately while non-paused stall transitions still use
settlingFalseEdges(PLAYBACK_STALL_GRACE_MS). Add a test covering a near-expiry
countdown when onPlayPause() sets isPaused = true, verifying it does not
continue through the grace delay.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1622f7aa-e147-45ff-8eff-1deb8a7b98af

📥 Commits

Reviewing files that changed from the base of the PR and between 9dace7f and cadab5b.

📒 Files selected for processing (6)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/SettlingFalseEdgesTest.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipController.kt
  • shared/src/commonTest/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipControllerTest.kt

Adds KDoc to the state type and the controller's public surface (observe,
cancelCountdown, reset) so the shared contract is readable without
tracing the state machine. Addresses the docstring-coverage check on
PR Silo-Server#210.
Drops the 750ms stall debounce on playbackActive. It delayed every
inactive signal, including an explicit pause, so a countdown close to
expiry could still fire after the user pressed pause. Pausing now stops
the timer immediately, and the countdown restarts from full on resume.

Removes settlingFalseEdges and its test along with it. Adds a controller
test pausing at 2.9s of a 3s countdown to pin that it stops rather than
skips.
@evulhotdog

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

evulhotdog and others added 2 commits August 11, 2026 01:21
…ebuffer filter

Review findings against PR Silo-Server#210 (evulhotdog), fixed on top of that branch
merged with current main.

Back was handled only in the Activity key bridge. On API 36 Back never reaches
dispatchKeyEvent, so a countdown Back hid the controls or exited the player
instead of cancelling; on older Android the branch ran BEFORE the scrubber's
Back path, so Back during a scrub cancelled the countdown and left the scrub
running. Countdown-Back now sits in the BackHandler ladder below clean-seek and
scrub, and the legacy bridge is gated on the same conditions so the two agree.

The countdown prompt claimed focus unconditionally. The scrubber treats losing
focus as COMMIT, not cancel, so a prompt appearing mid-scrub committed a seek
the viewer never confirmed — the intro banner silently moving playback
position. It now takes focus only when no scrub or clean seek owns it; the
button still appears and is still reachable.

The PR's description says a brief rebuffer no longer resets the countdown, and
cites a SettlingFalseEdgesTest that does not exist on the branch. playbackActive
was raw `isPlaying && !isLoading`, and the controller restarts from full on any
pause, so every stutter granted a fresh countdown. Added the missing filter:
settlingFalseEdges passes true through immediately and only reports false once
it has held 1.5s. The author's deliberate "a real pause restarts it" test is
preserved — an earlier attempt to resume from remaining time failed that test,
which is what showed the filter was the intended fix rather than the controller.

NOT fixed, deliberately: after cancel or expiry the focused node is destroyed
and its replacement declines focus, so nothing owns focus. The suppression is
intentional (the viewer pressed Down to navigate away), and choosing a
successor is a design decision on the author's feature.

shared 1059, androidTvApp 993, lint clean.

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

Copy link
Copy Markdown
Contributor

Took a careful pass over this branch on a Google TV Streamer. The shrinking-fill button itself behaves well — these are three issues in how it interacts with the rest of the player, plus one thing I'd leave to you because it's a design call on your feature.

1. Back is handled only in the Activity key bridge

The countdown's Back branch lives in the Activity dispatchKeyEvent path. Two consequences:

  • On API 36, Back never reaches dispatchKeyEvent. Pressing Back during the countdown hides the controls or exits the player instead of cancelling it.
  • On older Android it runs before the scrubber's Back path. Back during a scrub cancels the countdown and leaves the scrub running — the opposite priority to the one the rest of the player uses.

Back needs to sit in the BackHandler ladder, below clean-seek and scrub, with the legacy bridge gated on the same conditions so the two paths agree rather than racing.

2. The countdown prompt claims focus unconditionally — and that commits a scrub

This is the one worth fixing even if nothing else changes. TvIntroAutoSkipBanner passes autoFocus = previousSlot.value != 2, so the button takes focus whenever it appears.

The scrubber treats losing focus as COMMIT, not cancel. So if the prompt appears while the viewer is scrubbing, it steals focus, the scrubber commits, and playback jumps to a position the viewer never confirmed. From their side the intro banner silently moved the video.

A mayTakeFocus parameter threaded into both slots fixes it — false while a scrub or clean seek owns focus. The button still appears and is still reachable; it just doesn't take focus out from under them.

3. The rebuffer filter described in the PR isn't on the branch

The description says a brief rebuffer no longer resets the countdown and cites a SettlingFalseEdgesTest — that test doesn't exist here. playbackActive is raw isPlaying && !isLoading, and the controller restarts from full on any pause, so every stutter grants a fresh countdown.

What's missing is the filter itself: let true pass through immediately, and only report false once it has held for ~1.5s. Worth noting your deliberate "a real pause restarts it" test is what pins this down — an attempt to fix it in the controller by resuming from remaining time fails that test, which is a good sign the filter is the right layer.

Not a bug, but you should decide

After cancel or expiry, nothing owns focus: the focused node is destroyed and its replacement declines focus. The suppression looks intentional (the viewer pressed Down to navigate away), so choosing a successor is a product decision on your feature rather than something to patch blindly.


I have all three fixed on top of your current head (6a3b0d96), including the missing SettlingFalseEdges and its tests, lint clean and the suites green. Happy to open it as a PR against tv-skip-intro-shrinking-fill so it lands as a commit on this PR with your authorship intact — just say the word, or take the description above and do it your own way.

🤖 Review assisted by Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt`:
- Around line 2192-2194: Update the playbackActive flow in TvPlayerViewModel to
include UiState.isPaused and bypass settlingFalseEdges when an explicit pause
occurs, emitting false immediately while retaining the grace period for
rebuffering. Add a near-expiry test covering onPlayPause() and confirming the
countdown is cancelled without the 1.5-second delay.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ecb4699d-0572-40c8-ae9c-c9b99659e309

📥 Commits

Reviewing files that changed from the base of the PR and between d4cbc54 and 6f3ad0c.

📒 Files selected for processing (6)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipController.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/domain/player/SettlingFalseEdges.kt
  • shared/src/commonTest/kotlin/org/siloserver/silo/domain/player/SettlingFalseEdgesTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt

Pressing Skip Intro with the controls up removed the focused prompt and  stranded focus. The skip handler now requests the scrubber, the same target D-pad Down from the prompt lands on.
Pausing waited out the 1.5-second "just a hiccup?" grace window, so the countdown pill kept draining under a paused picture and could even skip the intro after you'd stopped watching. A pause is a real button press, not a buffering stutter, so it now stops the countdown on the frame of the press. The grace window still does its job for actual stutters, and hitting play restarts the countdown from full.
@evulhotdog

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Quick104 added a commit that referenced this pull request Aug 17, 2026
Skipping intros stops being a switch and becomes a choice of three, on the
server's new playback.intro_skip_mode (contract revision 7, silo-server#660):

- never: entering an intro shows nothing.
- ask: a "Skip Intro" pill with a five-second wall-clock fill; expiry
  withdraws it without deciding anything, Select skips, Back dismisses,
  D-pad moves no longer stop the timer, pause freezes it.
- always: seek past the intro immediately and offer an undo — a muted
  "Intro skipped" caption over a "Watch Intro" button; Select plays the
  intro after all and it is not skipped again.

IntroAutoSkipController (shared KMP, drives phone and TV) is rewritten to
the spec's state machine — Hidden / Asking / Skipped, a per-intro resolved
set, select()/dismiss() that hand seeks back to the caller so room gating
still applies — and IntroAutoSkipControllerTest asserts the spec tables.
Both banners render both copies; TV and phone settings replace the switch
with Never / Ask to skip / Skip automatically (a compact option popup on
TV). The settings store prefers intro_skip_mode and falls back to the
deprecated auto_skip_intro boolean for a pre-revision-7 server.

SettingKeys.kt is regenerated at revision 7 and the conformance fixture
re-vendored; the test-only Kotlin resolver learns the profile_client scope
the fixture gained since revision 2.

Spec: silo-server docs/design/2026-08-16-intro-skip-mode.md. Builds on the
Android TV Skip Intro pill from #210.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quick104 added a commit that referenced this pull request Aug 18, 2026
…endering overhaul (#228)

* feat(tv): skip-intro countdown as shrinking-fill button

Design variant: the Skip Intro button's translucent background fill
drains left-to-right and empties as auto-skip fires. Auto-focuses so
D-pad Select skips immediately. Drops the Cancel affordance for this
variant; shared IntroAutoSkipController untouched (fill animates
between whole-second ticks).

* fix(tv): keep shrinking-fill pill small and drain continuously

Two bugs: the fill layer's fillMaxWidth/Height grabbed the screen's max
constraints, ballooning the pill across the screen (now sized to the
pill via matchParentSize); and AnimatedContent treated each per-second
CountingDown tick as a new target, recreating the subtree and restarting
the drain every second (now keyed on the state kind, with the fill
driven by one continuous Animatable over the full countdown).

* feat(tv): skip-intro fill creeps left-to-right, focus/back/pause polish

- Fill grows left-to-right (was draining right-to-left) and lands full
  exactly when the auto-skip fires; duration comes from the countdown's
  own remaining seconds at entry, not the configured total.
- Slightly larger pill (18sp, 32/18dp padding).
- Focus request now waits two frames so it doesn't fail silently; the
  button reliably has focus on popup.
- Back dismisses the banner for this intro via new controller dismiss()
  (stays hidden; no manual pill fallback).
- Moving focus off the button cancels the timer and leaves the solid
  manual pill in place; cancelCountdown no longer resurrects the banner
  after a dismiss or a completed fire.

* fix(tv): focus the manual skip pill, cancel on D-pad not focus loss

The focus-loss watcher inferred 'user moved off' from any focus change,
but focus here is transient (the player re-focuses its own overlay), so
it cancelled the countdown a beat after it appeared: the fill vanished
into the solid manual pill (reading as an instantly-full bar) and the
auto-skip never fired. Cancel now triggers on an actual D-pad direction
key press, which is what the requirement meant.

The manual pill also never took focus, so Select needed a navigate press
first. It now auto-focuses when it appears fresh, but not when it appears
because a countdown was cancelled (that would fight the user's move).

* feat(tv): intro skip-intro button with wall-clock countdown fill

Countdown variant of the skip-intro prompt: a dark pill in the lower
right whose translucent fill creeps left-to-right and lands full exactly
as the auto-skip fires.

- Fill is driven by the frame clock, not an AnimationSpec. Compose scales
  spec durations by the device's animator_duration_scale
  (MotionDurationScale); measured on a Shield at 0.5x, a 5s tween
  finished in 2508ms and the bar sat full for the last 2.5s. A countdown
  to an automatic action must report real time, so it ignores that
  setting while the decorative transitions still honor it.
- Countdown is gated on playback actually running, so the prompt and the
  timer start together instead of the prompt appearing partway into an
  already-elapsed timer.
- Select, D-pad cancel, and Back dismiss are handled in the player
  screen's root key handler: the banner is not reliably in the focus
  tree, so key modifiers on the button never fired.
- Back dismisses the prompt for the current intro via a new controller
  dismiss(); a D-pad direction stops the timer and leaves the solid
  manual pill in place.
- Buttons use a single focus target (clickable owns it); the previous
  focusable+clickable pair split focus and key handling across two nodes,
  which required two Select presses to activate.
- Prompt drops toward the corner when the transport controls fade out and
  lifts back when they return.
- Unfocused state is dimmed rather than lit.

* fix(tv): address review findings on intro skip prompt

- Back during the countdown consumes both key phases. Consuming only
  ACTION_DOWN leaked the ACTION_UP to the activity back dispatcher, where
  whichever BackHandler happened to be topmost would also fire. Matches
  the sibling BACK handling already in this key handler.
- Debounce the false edge of playbackActive: isPlaying dips on every
  ExoPlayer rebuffer, and letting that through cancelled the countdown
  and restarted it from full mid-intro. A real pause still lands after
  the grace period.
- Share one countdown constant (IntroAutoSkipController
  .DEFAULT_COUNTDOWN_SECONDS) between the controller and the banner so
  the timer and the fill cannot drift apart.
- Cover the new shared state machine with focused tests: dismiss stays
  hidden inside the same intro, cancel-after-dismiss does not resurrect
  the pill, dismiss is per-intro, the countdown is held until playback is
  active, pause stops it and resume restarts from full, and cancel
  outside an active countdown leaves state alone.
- Drop dead plumbing the root key handler made redundant: the banner's
  onCancelCountdown param and BackHandler, the overlay's
  onCancelIntroAutoSkip param, and two unused imports. Fix KDoc that
  claimed Back was handled in the banner.

* fix(tv): stop the playback-stall debounce crashing on the first false edge

settlingFalseEdges used a flow {} builder with collectLatest, which runs
each value's block in a child coroutine; emitting into the enclosing
collector from there violates the flow invariant and threw
IllegalStateException the first time isPlaying reported false, i.e. on
every playback start.

Switched to channelFlow (the mitigation the exception itself names) and
hoisted the operator to an internal top-level function so it can be
tested. Added SettlingFalseEdgesTest covering the three contracts: true
passes straight through, a sub-grace false blip never surfaces, and a
false held past the grace period does.

* feat(tv): Back stops the intro countdown instead of dismissing it

Back now behaves like a D-pad nudge: it stops the timer and leaves the
solid manual Skip Intro pill in place. The press is still consumed so it
cannot also exit playback, and because the state is no longer
CountingDown afterwards, a second Back behaves normally.

That leaves nothing calling the controller's dismiss(), so the whole
dismissed-key path goes with it: dismiss(), dismissedKeys, the Hidden
branch in handle(), the dismissed check in cancelCountdown(), the
ViewModel and overlay/banner plumbing, and the two dismiss-only tests.
The per-intro test is retargeted at cancel, which is the behavior that
now exists.

* docs(tv): trim intro skip comments to what the code is and why

* docs(player): document the intro auto-skip controller API

Adds KDoc to the state type and the controller's public surface (observe,
cancelCountdown, reset) so the shared contract is readable without
tracing the state machine. Addresses the docstring-coverage check on
PR #210.

* fix(tv): stop the intro countdown the moment playback stops

Drops the 750ms stall debounce on playbackActive. It delayed every
inactive signal, including an explicit pause, so a countdown close to
expiry could still fire after the user pressed pause. Pausing now stops
the timer immediately, and the countdown restarts from full on resume.

Removes settlingFalseEdges and its test along with it. Adds a controller
test pausing at 2.9s of a 3s countdown to pin that it stops rather than
skips.

* fix(tv): Back priority, focus theft during a scrub, and the missing rebuffer filter

Review findings against PR #210 (evulhotdog), fixed on top of that branch
merged with current main.

Back was handled only in the Activity key bridge. On API 36 Back never reaches
dispatchKeyEvent, so a countdown Back hid the controls or exited the player
instead of cancelling; on older Android the branch ran BEFORE the scrubber's
Back path, so Back during a scrub cancelled the countdown and left the scrub
running. Countdown-Back now sits in the BackHandler ladder below clean-seek and
scrub, and the legacy bridge is gated on the same conditions so the two agree.

The countdown prompt claimed focus unconditionally. The scrubber treats losing
focus as COMMIT, not cancel, so a prompt appearing mid-scrub committed a seek
the viewer never confirmed — the intro banner silently moving playback
position. It now takes focus only when no scrub or clean seek owns it; the
button still appears and is still reachable.

The PR's description says a brief rebuffer no longer resets the countdown, and
cites a SettlingFalseEdgesTest that does not exist on the branch. playbackActive
was raw `isPlaying && !isLoading`, and the controller restarts from full on any
pause, so every stutter granted a fresh countdown. Added the missing filter:
settlingFalseEdges passes true through immediately and only reports false once
it has held 1.5s. The author's deliberate "a real pause restarts it" test is
preserved — an earlier attempt to resume from remaining time failed that test,
which is what showed the filter was the intended fix rather than the controller.

NOT fixed, deliberately: after cancel or expiry the focused node is destroyed
and its replacement declines focus, so nothing owns focus. The suppression is
intentional (the viewer pressed Down to navigate away), and choosing a
successor is a design decision on the author's feature.

shared 1059, androidTvApp 993, lint clean.

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

* fix(tv): land focus on the timeline after Skip Intro, not nowhere

Pressing Skip Intro with the controls up removed the focused prompt and  stranded focus. The skip handler now requests the scrubber, the same target D-pad Down from the prompt lands on.

* fix(tv): stop the Skip Intro countdown the moment you pause

Pausing waited out the 1.5-second "just a hiccup?" grace window, so the countdown pill kept draining under a paused picture and could even skip the intro after you'd stopped watching. A pause is a real button press, not a buffering stutter, so it now stops the countdown on the frame of the press. The grace window still does its job for actual stutters, and hitting play restarts the countdown from full.

* fix(tv): fit the phone-first sign-in on screen without scrolling

The ACCOUNT step stacked ~660dp of content into the TV's 540dp viewport
inside a verticalScroll. On entry, the focus claim on 'Sign in with a
password' scrolled the header chrome (wordmark, journey progress, eyebrow)
off the top — and since only the card's two buttons are focusable, there
was no D-pad path to ever scroll back. First-run users never saw the
onboarding progress indicator on this step.

Bring the branch inside the viewport instead of restructuring:
- outer vertical padding down to the 24dp overscan floor, and the
  header/eyebrow spacers to the compact values the password branch used
- QR card ghost buttons drop to the password card's compact 18sp spec
  (the 22sp default wrapped the long label to two lines), full-width
- card rhythm 12dp -> 8dp, redundant spacers around the divider removed,
  card 300dp -> 320dp so the compacted labels stay single-line

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

* refactor(tv): one control spec across the auth flow's forms

The four onboarding forms (server setup, sign-in, sign-up, first-run
setup) each sized their own controls: text fields at 60/52/default dp,
primary actions at 58/64 dp AuroraPrimaryButtons or 32dp TvHeroActionPills,
and two competing field-label idioms (mono caption above vs Material
floating label — the latter renders oversized in the border notch at TV
type scale, which is why sign-in dropped it).

Now every field is 56dp with the mono caption idiom and every primary
action is a 60dp full-width AuroraPrimaryButton, both sized from the new
TvAuthFormDefaults; card tertiary actions share the compact 18sp ghost
spec. Sign-up and first-run setup swap their pills for the Aurora
buttons, and their shared FieldText aligns to the flow's 17sp.

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

* feat(tv): land server-setup focus on the phone-pairing card

Companion pairing is the recommended path, so it gets first focus
(product call 2026-08-14, reversing the 2026-07-10 field-first default).
Auto-focusing the URL field also popped the IME over the form on
arrival, and the IME resize scrolled the header chrome off the top of
the 540dp viewport with no obvious way to bring it back.

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

* style(tv): match server setup to tvOS TVServerSetupView

Side-by-side against silo-apple's TVServerSetupView (1920x1080pt -> 0.5x
dp map): phone card pill moves top-leading and its copy left-aligns under
the centered beacon, adopting tvOS wording (iPhone -> phone); manual card
headline becomes 'Enter the server address', placeholder silo.example.com,
and the primary reads 'Connect to server'; a lock + 'Secure HTTPS is tried
automatically.' line fills the note slot when no error/cleartext notice
shows (truthful here — bare hosts probe https first); OR-divider hairlines
fade like tvOS; journey progress maps 430pt -> 215dp on both auth screens;
Headline drops to 20sp (36pt map + readability floor) so the longer
headline holds one line.

Kept deliberately divergent: URL shortcut chips instead of tvOS's
protocol/port disclosure, and phone-first default focus (tvOS still
defaults to the host field, which is harmless there — no auto-IME).

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

* fix(tv): pin the server-setup chooser to an exact height

heightIn(min) left the cards' max height loose, so fillMaxHeight was a
no-op, the phone card collapsed to its pill, and the weight(1f) box
holding the beacon and copy measured zero — the card body never rendered.
tvOS pins the same chooser to 580pt (frame(height:)); an exact 300dp does
the equivalent here and also keeps the IME resize from squeezing it.

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

* feat(tv): auth fields summon the IME on select, not on focus

Focusing any auth-form field (initial claim or D-pad travel) popped the
soft keyboard over half the form. The server-address field already
suppressed that with showKeyboardOnFocus=false plus an ENTER/SELECT
key-up handler; that handler is now the shared tvShowImeOnSelect()
modifier, applied with showKeyboardOnFocus=false to every field across
sign-in, sign-up, and first-run setup. Focus can rest on a field
silently; SELECT or a tap opens the keyboard, and the IME's Next/Done
actions still advance and submit.

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

* fix(tv): skip auth-form focus claims for pointer users

Opening the password form (or signup/first-run setup) by click still
popped the IME: a programmatic focus claim on a text field in touch mode
shows the keyboard even with showKeyboardOnFocus=false. Pointer users
can click the field themselves, so in touch mode the claim is skipped
entirely; D-pad users keep it (focus must land somewhere) and the
select-to-summon behavior from the previous commit applies.

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

* fix(tv): re-run auth focus claims when input mode flips back to keys

Skipping the claim in touch mode left a trap: after any pointer
interaction the claim was skipped (or had burned its retry budget on
buttons that refuse focus in touch mode), and when the viewer picked the
remote back up nothing re-claimed — no focus owner, dead D-pad. The
claims now key on the snapshot-backed input mode: skipped while Touch,
re-run the moment key input flips the mode. Sign-up/setup route the same
gate through rememberTvContentInitialFocus's contentKey (null in touch
mode), keeping its focus tracking attached unconditionally.

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

* fix(tv): stop the select KeyUp leak; add SiloTvFocus debug tracing

Activating 'Sign in with a password' delivered the tail KeyUp of that
same press to the just-focused username field, whose show-IME-on-select
handler acted on any select KeyUp — keyboard up, D-pad captured by the
IME, and the form's buttons unreachable. The handler now requires the
KeyDown to have landed on the field too.

Debug builds also get a SiloTvFocus logcat channel (window focus
gain/loss, splash key gate, focus claims + results + input mode, IME
summon/suppress decisions) so 'keys do nothing' reports can be told
apart: window-focus theft by the launcher logs a LOST line and then
nothing, an exhausted claim logs its result, and IME capture logs the
summon that caused it.

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

* fix(tv): hide the IME the legacy field pops on the login form's claim

The value-based text field shows the keyboard on a programmatic focus
claim regardless of showKeyboardOnFocus (SiloTvFocus traces, 2026-08-14):
the claim reports Focused, no select KeyUp reaches the field, and the IME
still appears. Hide it two frames after the claim so the form always
arrives quiet; SELECT or a click on the field still summons it.

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

* fix(tv): route vertical D-pad out of single-line auth fields

The legacy text field consumes DPAD up/down for cursor moves a
single-line box cannot make, so focus could never leave a focused field
by remote — 'Sign In / Back to phone sign-in / Change server' were
unreachable below the password field. The fields' shared key modifier
now hands vertical D-pad to FocusManager.moveFocus; an open IME owns
the keys before the app sees them, so this only applies to the quiet
field state, and left/right stay with the field for in-text cursor
movement.

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

* fix(tv): dismiss the stock IME when a select-to-show field is disposed

TvStockKeyboardPolicyTest pins the rule that any surface raising the
stock keyboard must also take it down on disposal — Android TV leaves it
floating over the next screen, still eating the D-pad. Moving the
show-on-select handler out of TvServerSetupScreen (which had the
disposal half) into the shared modifier dropped that half; putting
TvHideStockImeOnDispose() inside the modifier restores it for every
field that uses it.

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

* fix(tv): unblock the auth flow on a remote (review findings)

Six findings from the PR #228 review, all reproduced on an Android TV
emulator at 1920x1080 @ 320dpi. The three focus dead-ends were each
independently merge-blocking: on the affected screens the flow could not
be completed with a remote at all.

Focus traps. Only the first field on each screen carried
tvShowImeOnSelect(), so vertical D-pad reached the second field and
stopped: the single-line box consumes UP/DOWN for cursor moves it cannot
make. Setup's EMAIL/PASSWORD and signup's EMAIL/PASSWORD/INVITE now carry
it, so the first-run admin account can be created. TvAuthFieldEscapePolicyTest
pins the invariant per field and names the offender when it regresses.

showKeyboardOnFocus. The premise behind the whole quiet-arrival design was
unsound: foundation 1.8.0 documents the option as unsupported on the
`value: String` overload (BasicTextField.kt:639,796) that every
OutlinedTextField here uses, so D-pad focus still popped the IME. Moved
suppression into tvShowImeOnSelect(), which can tell a focus arrival from a
deliberate SELECT, and dropped the login screen's frame-counting workaround
that had been papering over one instance of it. Pointer users are exempt.

Key consumption. The stray-KeyUp branch logged "suppressed" and then
returned false, forwarding the very event it declined to act on; vertical
D-pad returned `moved`, handing a failed move back to the field that
cannot use it. Both consume now.

Create Account. `down = backToPhoneFocus` jumped over it, and the
intervening label is not focusable, so nothing caught the fall — the
button was unreachable on signup-enabled servers. The chain routes through
it in both directions when signupEnabled, and is unchanged without it.

Chooser clipping. The exact .height(300.dp) clipped the manual card:
"Connect to server" rendered as a blank pill, its label measured 6px in a
96px button, in the screen's default state. height(IntrinsicSize.Min) plus
a 300dp floor keeps fillMaxHeight resolving for the cards without capping
the taller one; the label now measures 48px.

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

* fix(tv): block the IME session instead of racing it closed

Focus arriving on an auth field popped the stock keyboard for 120-270ms
before the reactive hide landed, because Material OutlinedTextField sits on
the value: String BasicTextField, which requests the IME on focus and
ignores showKeyboardOnFocus. Hiding after the fact is a race we lose.

Refuse the platform text-input session up front with
InterceptPlatformTextInput, gated by a token-held gate that SELECT opens, so
the IME is never asked for. The interceptor instance is the restart signal:
it captures the gate value so remember() yields a new object on flip, which
is what lets SELECT summon the keyboard at all.

Verified on a Shield: mInputShown stays false on focus, true after SELECT.

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

* style(tv): centre the aurora eyebrow on its own axis

The eyebrow drew one leading hairline, so the Row centred but the label did
not, leaving it visibly right of the title stacked beneath it. Mirror the
hairline. Every eyebrow in the auth flow sits in a CenterHorizontally column,
so this is fixed in the component rather than per call site.

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

* fix(tv): fit the credential form inside the 540dp viewport

The sign-in branch measured 610dp against a 960x540dp TV surface, so the
root column scrolled and took the brand mark and step chrome off the top
with no D-pad way back. The signup-disabled case only appeared to pass at
539dp because the top padding was cheating 4dp under the overscan floor.

Restore the safe area on both branches, widen the card to 520dp, drop the
subtitle, and put the three secondary actions on one row instead of stacking
them. Measured at w960dp-h540dp: 456dp, or 481dp with an error showing.

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

* feat(profiles): display uploaded profile avatars

Profile dropped avatar_url at parse time, so the presigned object-store URL
— the only fetchable form of an upload, since the client cannot sign R2
requests — never reached the UI. resolveAvatarUrl then compounded it: seeing
a slash in "upload:profile-avatars/..." it treated the ref as a path and
built "$serverUrl/upload:...", a guaranteed 404 that rendered as initials.

Carry avatar_url and avatar_source on Profile, prefer the server URL, and
return null for an upload ref with no URL rather than fabricating one.
Ref and URL travel together as ProfileAvatarRef so a screen cannot be
half-migrated into showing initials while another shows the picture.

Presigned URLs expire in 900s, so cache the bytes under the signature-free
part of the URL: keying by the full URL would miss on every re-sign and
re-download forever. An avatar that loaded once keeps rendering from cache
regardless of URL age; a fetch that does fail retires the URL and falls back
to initials rather than an empty circle.

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

* fix(player): stop a stray media key from killing the app

A transport key on a TV remote arrives as startForegroundService, because
the manifest advertises MediaSessionService.SERVICE_INTERFACE with no
MediaButtonReceiver, so Media3 mints the media-button PendingIntent with
getForegroundService(). With nothing queued the player is idle on an empty
timeline, shouldShowNotification bails before onUpdateNotification is ever
reached, startForeground is never called, and the platform watchdog kills
the process ten seconds later. Observed twice on a Shield (API 30).

Media3 already ships the escape hatch — refusing the synthetic media-button
caller in onGetSession runs its own stopSelfSafely(), the only shutdown that
is legal for a foreground-service launch. We returned the session
unconditionally, so it never ran. Overriding onUpdateNotification would not
have helped; it is not reached on this path.

Also terminate the PiP branch when its action is unusable. That path cannot
crash (getService does not arm the watchdog) but a stale intent from a dead
process left an idle service holding a live player forever.

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

* fix(tv): draw the real wordmark in the shell top bar

The bar set the literal string "SILO" in FontWeight.Black — which the
branding guide lists verbatim under Don't ("typeset 'Silo' in place of the
supplied wordmark") — while the auth screens already rendered the genuine
asset. Use R.drawable.silo_wordmark in both.

24dp tall, the largest round value that keeps branding's clear-space rule
(one bar counter, 6.32% of lockup height per side) inside the 32dp bar row.
Untinted: the white lockup carries the signal palette in its three bars and
the guide forbids recolouring the mark.

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

* feat(tv): make Diagnostics its own settings category

Diagnostics was a row buried at the bottom of the Server pane that jumped to
a top-level route rendered outside TvMainShell — no top bar, no on-screen
Back. tvOS gives it its own rail category, fourth, before Server. Match that
and render it in the detail pane like every other category.

Delete the hand-rolled focus ladder rather than repair it. Up at the top of
the consent order returned the row itself and the key was consumed, so
SEND REPORTS TO, the destination rows and Privacy Policy were on screen but
unreachable by remote — the destination feature existed and could not be
used. Both destinations now sit behind a picker row, as on tvOS, and focus
is plain Compose focus search.

Drop read-only rows from the focus graph (Status, Destination, sent
history, empty states) so D-pad only stops on controls that act, and dim
disabled labels that previously painted white regardless of enabled.

Remove the Privacy Policy row: it was the only openUri in the TV app and
AndroidUriHandler throws when nothing can handle the intent, which is normal
on a Shield with no browser. The URL survives as text in the disclosure.

Sent history sits before the manual report because bring-into-view only
scrolls to reveal a focused node, so a trailing read-only block would be
permanently unreachable at 960x540dp.

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

* fix(tv): stop the diagnostics pane stranding its read-only rows

Dropping read-only rows from the focus graph left them strandable: Compose
scrolls only far enough to reveal the focused node, so FEATURE STATE sat
above the first focus stop with nothing focusable to scroll back to, and
Status/Destination could not be recovered once off screen. The same gap
between Crash Reports and Send Diagnostics Now — privacy footer, empty
pending row and the whole sent log — made one Down press jump ~320dp.

Give boundary rows a bring-into-view rect taller than themselves, the
tvImeAwareFieldContext idiom aimed at list edges, and group sections by kind
so the controls are contiguous: state, pending, controls, then the sent log.
The Crash Reports row now pre-reveals the footer that qualifies it, which
splits the long jump into 103dp + 124dp.

The clamp is load-bearing: Compose treats a rect overhanging both container
edges as already visible and scrolls by zero, so an unclamped reveal would
silently do nothing. Sent history drops to 4 entries because 5 left exactly
zero slack in the tail budget.

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

* feat(phone): restyle settings and drop the admin surface

Three interlocking changes; the files overlap too much to split honestly.

Settings looked default because its section headers rendered inside the
cards, no row had a description, nothing was divided, and pickers were
indistinguishable from read-only rows. Rebuild it against the webapp's
mobile language: grouped surface cards, headings above them, a description
on every row, hairline dividers that skip the first, and trailing value plus
chevron so a picker reads as one. 24 descriptions come verbatim from
contracts/settings/v1, the manifest Apple already shares, so the wording
matches across clients rather than being invented here.

The phone had no spacing or dimension tokens at all, so every dp was
hardcoded at its use site; add Spacing.kt and route the tree through it.
Settings cards were also abusing primaryContainer as a surface role because
the surfaceContainer* ladder was never populated — populate it. That also
fixes four cast components that were silently getting M3's purple baseline.

Admin and session management are gone from phone, TV and shared — not
hidden, deleted. The user's call; AGENTS.md is updated to record it as a
deliberate divergence from Apple, which still surfaces the STATS dashboard,
so nobody re-adds it as a missing feature. Device pairing stays.

The profile menu turned out to be three byte-identical copies, since Home
and Libraries paint their own chrome; restyling one would have left it
unchanged on the two screens it is most often opened from. Consolidated to
one ProfileMenu behind three anchors, with a test that fails if a fourth
appears. Sign out now confirms from both entry points through one shared
dialog, and the copy states what actually happens: logout keeps downloads
and the server registry, verified against AuthRepository.

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

* feat(player): expand video past bars encoded into the picture

Scope films arrive hard-matted: the source here is a 3840x2160 coded frame
with the 2.39:1 image sitting inside ~277px of baked-in black per edge,
because Blu-ray and UHD only permit 16:9 frame sizes. FIT fits the coded
frame, correctly, so a 16:9 frame on a 19.5:9 panel pillarboxes by 280px a
side and the file's own matte adds ~184px more. Four-sided waste, no bug.

The server cannot answer this — its aspect_ratio comes from ffprobe's
display_aspect_ratio and reports 16:9 for exactly these files, with no
cropdetect anywhere. So measure the matte from sampled frames and promote
FIT to ZOOM only while the clip provably falls inside it.

Sampling uses PixelCopy off the video SurfaceView rather than a TextureView
or a GL effects chain, so tunneled decode, HDR10 and Dolby Vision
passthrough are untouched — the readable-frame alternatives break all three.

Gated on proof, because guessing here costs picture: four consecutive
frames must clear the clip by 2%, one frame that stops clearing reverts
immediately, a fade to black counts as no evidence, and two engage/revert
cycles latch it off for the item. Off by default, device-local per profile.
videoGravity keeps its meaning — fill and stretch bypass this entirely.

Blanket ZOOM was rejected: it is only lossless when the image is wider than
the display, and would cut ~128 source px per edge off a 1.85:1 film.

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

* feat(player): fill the screen by default, clear of the camera

Expanding past a file's encoded matte was shipped as an off-by-default
toggle, so the normal case was something you had to discover. Make it the
default and give the cutout its own choice, since once the image fills the
width the punch-hole lands on the picture rather than on a bar.

Fill the screen: Clear of camera (default) / Full width / Off. Defaulting on
is safe because the gating is unchanged — without proof the crop falls
inside encoded black it stays at FIT, so the failure mode is today's
behaviour, not a cropped picture.

Inset symmetrically rather than only on the camera's edge, giving up 10% of
image area. The cutout swaps sides between ROTATION_90 and ROTATION_270, so
a single-edge inset would slide the picture 139px sideways when the phone is
flipped end for end; it also reads as a rendering fault next to the
symmetric letterbox above and below. A default has to be unimpeachable.

Narrowing the box to clear the camera also shortens the image, so the
default keeps ~126px top and bottom rather than the ~68px Full width gives.
Bars on four sides still, but 23% more picture than FIT.

Content with no stored bars has nothing to eat, never reaches the engage
threshold, and stays exactly where FIT puts it — pinned by a test and
stated in the setting's copy so an unchanged TV episode does not read as a
broken feature.

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

* fix(player): fit the measured content rect, not zoom to width

Promoting FIT to ZOOM only ever asked "can I fill the width". For content
narrower than the display — a 1.90:1 series on a 2.167:1 panel — that needs
far more crop than the matte can absorb, so it declined and left the picture
short of the top and bottom edges even though it would have fit easily.
Right answer to the wrong question.

Fit the measured content rect to the box instead and let residual black fall
where the aspects genuinely differ. One rule covers both: content wider than
the box fills the width and keeps a real letterbox, content narrower fills
the height and keeps a real pillarbox, content with no matte does not move.

This is strictly safer than the threshold it replaces. With scale
s = min(Bw/Wc, Bh/Hn), the horizontal is never clipped at all, and the
vertical clip is (Hn·s − Bh)/2 + M·s — exactly the scaled matte when height
binds, strictly less when width binds. Bounded by construction rather than
by a guard, so the crop fraction leaves the decision path entirely.

The remaining margin covers measurement, not arithmetic, and is now
proportional: a flat 2% of coded height was a rounding error against a scope
film's 12.9% matte but ate two thirds of a 1.90:1 title's 3.3%, which is
what half-declined the reported case.

Hold the estimate as a running minimum. A dark scene can no longer widen the
crop, a frame with picture at the matte edge narrows it permanently, and a
monotone minimum cannot oscillate — so instant revert and latch-off both
fall out of one property instead of two thresholds.

Expansion was also visibly late. Nothing animated it; it was latency. First
samples now run at 100ms rather than 250ms, and the measured rect is cached
per coded resolution and read during composition, so a replay or a resume
starts already expanded — 300ms cold, 0ms cached. Live frames replace the
seed outright, so a stale entry self-corrects and is never written back.

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

* fix(player): anchor subtitles to the visible content frame

selectSubtitleCanvasRect preferred displayedVideoRect under ZOOM/FILL
whenever the frame's visible size differed from the view's. The intent was
right — a zoomed video covers the whole view, so captions should too — but
that rect is measured from the PlayerView's top-left while applyRect applies
it as margins inside the content frame. Right idea, wrong coordinate space.

It only bites when the frame is offset inside the view, which is exactly
what fitting the content rect produces: on a 2:1 title the view is 2814 wide
while a stale fitted frame is 2560 inset by 127, so a view-space rect applied
at frame margin zero pushed captions 127px right of centre. The same
fallback spanned the full 1583-tall frame against a 1440 view, drawing
captions 71px below the screen. Off-centre horizontally and clipped
vertically were one bug, not two.

Anchor to the content frame's visible intersection in every mode, which is
the space applyRect already speaks. displayedVideoRect survives only for
having no frame at all, so resizeMode no longer selects anything and the
parameter goes. Captions now centre on 1560 in all three fill modes at both
landscape rotations, and the canvas bottom lands on the visible edge.

The clamp's original case — 4:3 on a 16:9 TV — is better served too: it also
has an inset frame and was getting the same wrong-space treatment.

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

* fix(phone): centre the landscape player HUD on the display

The fullscreen player controls were padded by the raw safeDrawing insets,
which are lopsided in landscape (camera cutout on one edge, nothing on the
other), so the toolbar, progress bar and transport row all sat off the
device's centre line.

- Anchor the skip/play/skip cluster to the true centre of the overlay
  instead of the inset-padded column.
- Apply the larger horizontal safe-drawing inset to both sides so the
  toolbar and progress bar stay clear of the camera and symmetric.

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

* feat(tv): show the skip chip on every seek and report the burst total

The ±delta chip only appeared for hidden-controls D-pad skips. The transport
buttons and the remote's skip keys took the reveal path, which showed the
overlay but no chip — so a press just made the bar twitch. Both paths set
the chip now.

The chip also reported the per-press constant, not the coalesced burst:
three fast forward presses coalesce into one +90s seek (200ms trailing-edge
QuickSkipAccumulator, matching tvOS) but read "+30s" three times, which made
the debounce look like dropped presses. The view model exposes the burst
origin so the chip shows +90s.

With the transport visible the chip drops its own track line — the live
scrubber already reports position — and sits in the gap above it. One
render site across both cases so the reveal-path skip doesn't tear down one
AnimatedVisibility and fade in another mid-transition.

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

* fix(tv): make the hidden-controls hold-seek honest about its rate

The hidden-controls scan advanced a flat 2s of content per 100ms tick at
"1×" — 20 seconds per real second — so every multiple on the chip was a
twentieth of the truth: "8×" scanned at 160×. The focused scrubber's
hold-seek had already been corrected onto TvSeekRateLadder (rate × tick
seconds per tick), which left the same gesture running 20× faster with the
chrome hidden than with it up.

Both paths now walk one ladder. Ticks cover rate × real time; the ceiling
is derived from the runtime instead of a fixed 32×, since at honest rates a
fixed ceiling cannot serve both a 22-minute episode and a three-hour film;
and stepping "slower" past the bottom stops at the base rate instead of
crossing zero and silently reversing direction (the old signed ladder ran
… -1, 1 …). Base rate is 2× — 1× scans at playback speed, so a press did not
visibly move.

tvOS carries the same 2s-per-tick arithmetic (holdSeekBaseStep); filed as
Silo-Server/silo-apple#165.

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

* feat(tv): route the settings HUD by entry point, like tvOS

D-pad Down from clean playback now opens the HUD, landing on Audio if the
title has audio tracks, else Subtitles, else Video (tvOS
preferredPlaybackHUDTab). The remote's Menu/Settings key and the transport's
Tune button — the same settings entry point — both land on Video (tvOS
applyHUDEntryPoint(.settings)). Previously Down only focused the transport
and every entry hard-reset to Info, so changing an audio or subtitle track
meant traversing two to four panes from Info every time.

Down with the transport overlay already up is unchanged: it still moves
focus into the button row under the scrubber. The old single OpenHud action
splits into OpenSettingsHud and OpenPlaybackHud, gated by a dpadDownOpensHud
flag that defaults off so the overlay's own key handler keeps its behaviour.

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

* fix(tv): dismiss the settings HUD with a single Back press

Two independent faults each cost an extra press.

openHUD() forced showControls true — invisible while the HUD is up, since
the transport overlay is gated on !hudOpen — but closeHUD() never put it
back. Closing a HUD opened from clean playback therefore revealed a
transport overlay nobody asked for, and a second Back was needed to reach
the picture. closeHUD() now restores whatever the chrome was on open.

The HUD's key handler consumed Back only on KeyUp. Compose maps an
unconsumed Back/Escape KeyDown to FocusDirection.Exit
(FocusInteropUtils.toFocusDirection) and AndroidComposeView runs a focus
search on it, which moved focus out of the HUD before the UP arrived; key
events route only to the focused subtree, so the UP never reached the
handler. The panel stayed up with no focused pill and the second press,
now unconsumed end to end, closed it via BackHandler. The handler consumes
the DOWN as well. This is the same mechanism behind the 2026-07-08 QA note
on the transport overlay ("deselected the button instead of dismissing"),
which was fixed at the key bridge without naming the cause; the same
KeyUp-only idiom remains in TvMainShell, TvLibraryBrowseControls,
TvMediaInfoDialog, TvPersonDetailScreen and TvPlayerScrubber.

Debug builds now trace Back through the bridge, the HUD handler and the
BackHandler ladder, plus HUD focus gain/loss and open/close, on the
SiloTvFocus channel — that trace is what told the two faults apart.

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

* feat(tv): redesign the player settings HUD

Same structure — pill tabs, panes, picker — proportioned and wired like
TVPlayerInfoHUD instead of a portrait slab on a landscape screen.

Composition. The panel rendered at 490dp, not the intended 680: it was
`.widthIn(max = 680).fillMaxWidth(0.72f)`, and in that order fillMaxWidth
takes its fraction of the already-capped max. That one accident was the
tab-rail clipping ("Chap…") and most of the cramping. Now fillMaxWidth then
widthIn, at 74% / 720dp. The tab rail floats over the video and the card
beneath holds only the pane (tvOS 1100×380pt on 1920×1080: wide and short),
wrapping its content between 156 and 300dp instead of a fixed 360dp — a
two-row Audio pane is a two-row card. Idle pills take the tvOS dark fill and
hairline so they survive bright frames; the card is near-opaque (0.96) for
legibility, since it is a settings surface read from the sofa.

Panes. Two columns everywhere: Stats is a 5+4 grid instead of one column
with each value 500dp from its label; Audio gains a read-only Output column
(codec, passthrough vs PCM, decoder) instead of a lone full-width column;
Video is rebalanced from 8-vs-1 to Playback | Output + Automation, so all
nine controls fit without scrolling. Info shows "H.264" / "DTS-HD" instead
of shouted mimes; Stats keeps the raw strings.

Focus. The card is a focus group with custom enter/exit: Down from a pill
lands on the pane's first focusable row (top-left), and Up from anywhere in
the pane returns to the SELECTED pill — with focus-driven selection, the
nearest pill would switch panes as a side effect of leaving (tvOS
defaultFocus(activeTab), for the same reason). Panes attach one shared
entry requester to their first enabled row; read-only panes fall back to
the default search rather than redirecting to an unattached requester,
which would cancel the move.

Toggles. The five boolean rows (HDR passthrough, Dolby Vision, Auto-skip
intro, Auto-play next, subtitle Outline) flip in place on Select with no
chevron and no picker, matching tvOS HUDToggleRow. The selected pill dims
while focus is down in the pane, so the one solid-white element on screen
is the control you are on.

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

* fix(settings): push pending writes before pulling from the server

Local writes sit in ServerSettingsFlusher's ~750ms debounce. A
refreshFromServer() inside that window read the server's OLD value for the
key and wrote it back over the change the user had just made. The TV player
refreshes at every load, so a setting toggled in the HUD and followed by an
in-place session restart reverted every time; the detail screen refreshes
on entry, so a setting changed just before opening it could revert too.

refreshFromServer() now drains the flusher first, so the pull observes the
write. Offline, both fail and the local value stands.

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

* feat(tv): apply a Dolby Vision toggle to the current session

Toggling Dolby Vision in the HUD only ever took effect at the next playback
start. Local track selection re-applied immediately, but for a single-track
DV file — the common case — the part that matters (base layer vs DV
delivery) is decided in the server's plan from the capability snapshot sent
at load. Nothing on screen said so, so the toggle read as dead.

The toggle now restarts the session in place at the current position when
the current file is Dolby Vision, reusing the version-switch path so the
old session stays mounted until the replacement is ready. onSelectFileVersion
and this share the extracted restartSessionInPlace(). A non-DV file has
nothing to re-plan and is left alone.

The row says what is happening: "Off · Applying…" from the press until the
replacement session is adopted AND playing — adoption is quick, but the
viewer's wait is the rebuffer after it — with a 20s cap so it cannot stick
if the replacement never arrives. Presses are swallowed meanwhile rather
than the row disabled: a disabled row is not focusable, and dropping focus
off the row the viewer just pressed left the next press landing on nothing.

HDR passthrough is deliberately not restarted: it feeds only local Media3
track presets (allowHdr) and is not part of the server plan, so a restart
would change nothing.

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

* feat(tv): show codecs in the Version selector like tvOS

versionShortLabel now carries resolution · video codec · DV/HDR · audio
codec, matching DetailPlaybackFormatting.versionShortLabel on tvOS, so
the detail Version pill reads "4K · HEVC · DV · TrueHD" instead of
"4K · DV". The player HUD's Version row shares the helper and follows.

versionPickerLabels drops its codec discriminator (codec is now part of
the base label) and disambiguates colliding rows by size, then container.

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

* feat(tv): style the playback selector menus like the top-bar cascade

The Version / Audio / Subtitles / Edition dropdowns now draw the same
Skyline glass panel as the library and For You selectors: dim uppercase
header, bare rows that invert to the white capsule on focus (semibold
title, dimmed detail, leading check slot), hairline + hint footer. The
Material DropdownMenu remains only as the invisible anchored host.

The option list is capped at ~6 rows and scrolls inside the panel with
the header and footer pinned, so a long subtitle list no longer runs off
the bottom of the screen; the rows fade out (DstIn mask, so no colour
seam against the translucent panel) with a chevron on whichever edge
still has more rows.

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

* feat(tv): choreograph the detail page's section scrolls

Focus entering Cast, Details or More Like This now anchors the section
top to a fixed viewport line (18%), the way the episodes section already
centered itself, so every Down between sections is a uniform step rather
than a gutter nudge of a different size. One 400ms fast-out/slow-in spec
drives every scroll on the page (anchors, return-to-hero, bring-into-view
fallback) instead of the 260ms/620ms ease-in-out mix.

The hero backdrop now recedes with the scroll — fades toward the page
background over the first 40% of its height and drifts at 0.4x (light
parallax) — read in the draw phase so scrolling never recomposes the hero.

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

* feat(tv): pin the focused card in horizontal rails

Card carousels (TvMediaRow, Cast & Crew, episode rail) now scroll with a
shared bring-into-view spec that keeps the focused card's leading edge at
the row's start padding — the tvOS/Netflix rail model — so every Right or
Left is one uniform card-sized glide (480ms fast-out/slow-in, tuned on the
Shield) instead of a variable nudge once the card reaches the trailing
gutter. Row ends still clamp; vertical requests keep bubbling to the
enclosing column's own spec.

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

* perf(tv): stop per-keypress recomposition churn in rails and the hero

Rail pin: express the focused-card pin as a one-shot clamped
animateScrollBy on focus (tvRailPinOnFocus) instead of as the
BringIntoViewSpec's scroll distance. Compose re-launches the
bring-into-view scroll on every layout pass while the spec still reports
a non-zero distance, and a pinned position is unreachable when the row is
clamped at either end, so a concurrent vertical row scroll spun a new job
per frame — measured on the Shield as p90 121ms and near-frozen vertical
navigation. The rail's automatic spec is now a satisfiable minimal reveal
with the same 480ms motion.

Composition cost per focus move:
- TvMediaRow remembers each card's action bundle; the producer built four
  fresh lambdas per card per pass, so no visible card could ever skip.
- The Home feed builds the return-target section map once per rows
  snapshot rather than copying every content id on each keypress.
- CardOverlays remembers the preset style and the resolved badges per
  corner instead of recomputing them for every card composition.
- The card context menu returns before allocating its focus requester and
  popup positioner while closed.
- The root hero backdrop reads its animating tint in the draw lambda (it
  recomposed the whole backdrop + crossfade every frame of the tween) and
  caches its two mask brushes per size.
- Cast rail remembers cast.take(24); card shapes hoisted.

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

* build(tv): add a Baseline Profile generator for the TV app

ART will not AOT-compile debuggable builds and only compiles release
builds during idle-time dexopt, so first launches JIT the Home feed, top
bar and cascades while the user is navigating. Measured on a Shield:
47% janky / p90 121ms JIT-warm vs 26% / p90 29ms once compiled.

:baselineprofile-tv is the TV twin of :baselineprofile — a macrobenchmark
targeting :androidTvApp that records cold start plus a d-pad browse of
Home. It runs against a connected, signed-in TV (a headless managed
emulator would only ever record the login screen):

  ANDROID_SERIAL=<tv> ./gradlew :androidTvApp:generateBaselineProfile \
      -PallowDebugReleaseSigning=true

:androidTvApp applies the consumer plugin and merges the generated
profile into release APKs; profileinstaller was already linked.

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

* build: verification metadata for the TV nonMinifiedRelease classpath

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

* docs(tv): note the API 33+ requirement for baseline profile collection

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

* chore(player): opt-in subtitle placement geometry logging

Adds a SiloSubtitleGeom debug tag (enable with
`adb shell setprop log.tag.SiloSubtitleGeom DEBUG`) that prints where the
caption canvas landed in window / PlayerView / content-frame space and the
cues' own anchoring, so a misplaced caption can be attributed to the right
coordinate space from device output instead of static reading. Silent
unless enabled.

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

* fix(tv): left-align the marquee logo and keep grid rows clear of the top bar

The Home marquee logo drew centred in its full-width block (Coil's default
alignment for a Fit image), floating wide logos away from the meta and
synopsis left edge; ThumbhashImage gains an alignment passthrough and the
marquee pins the logo CenterStart within its 440dp max width.

Vertical grids (Collections, audiobook groups, library browse, TvCatalogGrid)
use a bring-into-view spec whose leading gutter is at least the grid's top
content inset, so a row revealed by scrolling back up parks below the top
bar instead of at 12% of the viewport (~65dp on a 1080p canvas, under the
94dp bar), which left the first row's posters cut off.

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

* fix(tv): return from a collection to the card it was opened from

The Collections/Recommended/Browse pill selection lived in a plain
remember in the shell; opening a collection is an outer route that takes
the shell out of composition, so on Back the nested nav restored the
Movies tab but the pill was gone and the tab re-landed on Recommended,
reading as "Back went Home". The selection is now saveable.

Collection opens also arm the shell's content hand-back (as item-detail
opens do), so the return resume claims content synchronously instead of
letting the default search settle on the top bar for a beat; and the
Collections grid remembers the last-focused card (saveable), scrolls it
into composition and makes it the grid's entry target, so focus lands on
the exact card that was clicked.

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

* fix(tv): land content Up on the selected tab, and return to the browse card

Content->bar Up is meant to land on the selected tab, but from a control
that sits directly in the screen (the For You filter pills) Compose's 2D
search still escaped the content group — the group's exit=Cancel only
guards the level of the search that owns the focused row — and landed on
the geometrically nearest bar item, the Search icon from the left edge.
A move that leaves content is now treated like a failed move and routed
to the selected tab. Watchlist/Favorites (For You's dropdown pages, not
tab roots) map to the For You tab for that routing.

Library tabs, the Libraries screen, Watchlist/Favorites/History and
Browse now open item detail through the shell's content hand-back, and
the browse grid points its focus entry at the return-target card while
attached, so Back from a detail lands straight on the card that was
opened instead of the Sort button first.

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

* fix(tv): let Up leave the calendar's first day shelf

The shelf→controls hand-off judged arrival by "the list has focus",
which is already true while a shelf card is focused, so the claim
returned focused without ever requesting the week-strip date and Up from
the first shelf was a silent no-op that re-armed 80ms later. Arrival is
now observed on the controls row itself, and that row (list item zero,
which the shelf snap scrolls out of the composed window) is scrolled
back into composition before the claim so its requester is attached.

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

* feat(for-you): render For You with the Home hero + carousel rows

TV: TvRecommendationsScreen now renders TvSkylineSectionFeed (surfaceKey
"for_you") — the same focus marquee hero, ambient backdrop and poster rows
as Home — with Home's detail-return / first-row / up-fallback wiring. The
in-screen For You/Watchlist/Favorites pill band is gone; the top-menu For
You dropdown is the only switch. Watchlist/Favorites still render inline
with focus landing in the grid. Home's detail-return helper is generalised
to TvDetailReturnFocusState so both Skyline roots share it, and the
For-You-only solid top bar, top-anchor and focus-bridge code (and their
tests) are removed.

Two shell fixes surfaced on the Shield: a dropdown pick now resets the For
You detail-return token (a stale token made the feed swallow the entry
focus bump, dropping focus onto the Search icon), and the entry-request
counter is saveable so a recreated shell cannot restart it below the
screen's saved high-water mark (which silently ignored every later pick).

Phone: RecommendationsScreen shows a FeaturedCarousel built from the
server's "for-you-main" row (flagged featured in the shared conversion)
above HomeSectionRow rows, matching the Libraries Recommended shape; the
hero row stays as a row too so nothing past the carousel cap is lost.

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

* fix(tv): order the For You dropdown Recommendations, Favorites, Watchlist

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

* fix(tv): stop clipping the detail page's season chips and Details focus box

Move the season picker's safe-area inset into the LazyRow contentPadding so
the first chip's focus scale isn't clipped at the row edge, and give the
Details section an inner inset so its focus highlight frames the text
instead of starting flush at its left edge.

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

* fix(tv): match the Collections grid cards and group headers to Browse

Collection poster cards used the bare TV Material Card defaults, so they
read as a different card family next to Browse. They now share
TvMediaCard's focus treatment (scale, accent border, glow) and caption
metrics (11dp gap, 15.5sp start-aligned title that brightens on focus).
The "12 MOVIES" count line is dropped — it doubled the caption height —
and the monospace tracked-caps group header is replaced by the shared
TvSectionHeader used by Home/Recommended rows, nudged toward its own group.

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

* fix(tv): only show the For You fallback caption after an actual fallback

"No recommendations yet — showing your saved titles." was keyed on
"saved list showing + no visible feed rows", which is also true when the
user picks Favorites/Watchlist before discover has loaded, so it flashed
on first open. Track the auto-fallback explicitly and clear it when the
user picks a list or recommendations arrive.

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

* feat(tv): sort and filter for collection, Favorites, and Watchlist pages

The library collection detail page and the Favorites/Watchlist pages
(For You inline + standalone) get the Browse Sort/Filter pills and an
item count, rendered as the grid's header row so they scroll with the
content instead of clipping rows beneath them.

Sort offers the server's own order as the default ("Collection Order" /
"Recently Saved" — no sort param sent) plus Title, Date Added, Year,
Rating, Runtime; the filter panel is the Browse facet panel with the
vocabulary scoped to the collection or list via
/catalog/filters?source=…. Facet groups and match=all|any go through
the same bracket encoding as Browse (extracted into a shared helper).

Shared: CatalogResponse.effectiveSort, sort/order/facet params on the
library-collection items call and getFilters(source, collectionId), and
PersonalListQuery + applyQuery on PersonalListViewModel. Favorites and
Watchlist now fetch via /catalog?source=favorites|watchlist (identical
default order, and it reports total, which the legacy routes do not).

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

* fix(tv): make the subtitle transaction adapter the only subtitle authority

TV had two independent subtitle-selection authorities. The legacy ordinal path
(onTracksChanged -> resolveAutoPreferredTextSubtitle -> a bare SharedFlow<Int>
-> backend.selectSubtitle) selected a text track straight at the player without
arming a mount owner, so onSubtitleSelectionApplied hit its silent
`pendingSubtitleMountAcknowledgement ?: return` and the transaction adapter
never learned anything. Its committed identity stayed as seeded from the plan,
which the HUD renders. On an "English - Always" profile with an embedded PGS
track the result was subtitles on screen and "Off" in the HUD. The
DefaultTrackSelector's preferred-text hint was a third, silent selector on top.

- Auto-preference, detail-page restore and Off all resolve to a typed
  SubtitleIdentity (the same tvMountedSubtitleIdentity mapping the HUD options
  use) and go through the adapter via a new selectAuto(), which commits like
  select() but does not cancel an in-flight refresh.
- An automatic pick no longer writes the durable per-item preference:
  tvSubtitlePersistenceUpdate Preserves it while the committed identity is the
  one the app chose, and any viewer pick clears the marker and persists.
- Mount requests are typed and carry their owner (TvSubtitleMountRequest), so
  an ownerless mount is unrepresentable and the silent bail-out is gone; a
  rejected mount is now logged under the TvSubtitle tag. Acknowledgements also
  release the remount latch's resolved owner, which nothing was doing.
- The TV selector preset no longer sets a preferred text language, and the
  factory does not forward one. Text enablement is left untouched so
  re-applying presets on a capability change cannot disturb a mounted subtitle.
  The phone preset is unchanged.
- Small reconciliation in onTracksChanged adopts a text track selected by
  anything outside the app (device caption settings, selector quirks) into the
  adapter and logs it loudly. Safety net, not the mechanism.
- The HUD Info tab reads the same committed identity as the Subtitles tab
  instead of asking Media3 which track is selected, so the two cannot disagree.
- Deleted the dead persisted-subtitle-fingerprint restore path, which load had
  been clearing unconditionally since the fresh-restore rework.

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

* fix(tv): mount an already-mounted subtitle in place instead of replanning

Since the transaction adapter became the only subtitle authority, the launch
auto-pick of an embedded PGS track ("English - Always", direct-play MKV) tore
the stream down: subtitle_replan_mount, a new session, a media-item swap, ~6-8s
of rebuffering and a server-extracted duplicate of the same PGS track mounted
alongside the one already on screen.

Root cause is an asymmetry between the two mounted-subtitle resolvers. Protocol
v3 types EVERY non-burn-in inventory row `delivery = sidecar`, including a row
that merely describes a track muxed into a direct-play stream, so
playbackSubtitleIdentity returns ServerSidecar for it before it ever reaches its
embedded branch. tvMountedSubtitleIdentity still maps the mounted track onto
that row -- the PlayerSubtitleInfo overload of resolveMountedSubtitle matches on
typed metadata -- but the SubtitleIdentity overload matches a sidecar by its
authored `silo-subtitle:N` id alone, which a muxed track can never carry. So the
identity the app derived FROM a mounted track answered "not mounted" when asked
about that same track: isLocallyMountable said false, commitLocallyMountableSelection
declined, and applySelection fell through to the staged server replan.

The pending audio/quality guard and the publication of subtitleTracks into
uiState were both ruled out: onTracksChanged updates uiState before the auto pick
runs, and a launch-time SelectSubtitle carries no audio or quality preference.

- tvResolveMountedSubtitleTrack resolves an identity onto a mounted track
  through the inventory row it was minted from when the identity resolver alone
  cannot, so the two directions of the mapping can no longer disagree. Only an
  identity that is exactly some row's identity gets that fallback, and it must
  still find a mounted track -- catalog-only rows, sidecars the player has not
  loaded and burn-in rows still answer null and still replan.
- The ViewModel's isLocallyMountable and the remount latch both use it, so a
  selection committed locally can also resolve the ordinal it must mount. The
  latch keeps exact-id-only matching for identities carrying a real Media3 id;
  only a sidecar id, which we author rather than the stream, may fall back.

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

* fix(player): make Position and Size reach PGS/DVB subtitles

Changing Subtitle Position or Size in the player HUD did nothing to an
embedded PGS track. Media3 1.10.1's SubtitlePainter reads the caption
style, the fixed text size and the bottom-padding fraction in its TEXT
branch only; setupBitmapLayout() derives the destination rect purely from
the cue's own position/line/size/bitmapHeight and anchors. Everything
SubtitleManager.applyAppearance sets is a no-op for a bitmap cue by
construction, and Silo's cue pass-through deliberately left bitmap cues
untouched.

So bake the two presets that CAN be honoured into the cue itself, in the
same forwarding pass that already neutralizes the WebVTT full-width
default:

  - remapBitmapCue re-anchors every bitmap cue's bottom edge to the
    preset's padding (shared with the text path through the new
    subtitleBottomPaddingFraction, title-safe correction included) and
    scales size/bitmapHeight about the cue's own horizontal centre,
    clamped to stay on the surface. Authored horizontal placement and
    anchors are preserved and re-expressed; a cue without a usable
    size/bitmapHeight/position is returned untouched, as are text cues.
    PgsParser and DvbParser both emit START-anchored top/left fractions
    with LINE_TYPE_FRACTION (verified against the 1.10.1 sources).
  - The size ladder is 0.85 / 1.0 / 1.15 / 1.3 / 1.5, Medium being the
    authored typesetting. It follows the shape of the television text
    ladder without its 1.8x top end: a PGS cue is fixed-resolution pixels
    and every step above 1.0 is upscaling.
  - SubtitleVideoRectSync now holds the appearance and the last cue group,
    so a Position/Size change re-forwards the caption currently on screen
    instead of waitin…
@Quick104 Quick104 closed this Aug 18, 2026
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.

3 participants