feat(tv): skip-intro countdown as shrinking-fill button - #210
feat(tv): skip-intro countdown as shrinking-fill button#210evulhotdog wants to merge 22 commits into
Conversation
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.
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe 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. ChangesIntro auto-skip behavior
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.ktandroidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.ktandroidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.ktandroidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/SettlingFalseEdgesTest.ktshared/src/commonMain/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipController.ktshared/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.
|
@coderabbitai review |
✅ Action performedReview finished.
|
…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>
|
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 bridgeThe countdown's Back branch lives in the Activity
Back needs to sit in the 2. The countdown prompt claims focus unconditionally — and that commits a scrubThis is the one worth fixing even if nothing else changes. 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 3. The rebuffer filter described in the PR isn't on the branchThe description says a brief rebuffer no longer resets the countdown and cites a What's missing is the filter itself: let Not a bug, but you should decideAfter 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 ( 🤖 Review assisted by Claude Code |
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.ktandroidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.ktandroidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.ktshared/src/commonMain/kotlin/org/siloserver/silo/domain/player/IntroAutoSkipController.ktshared/src/commonMain/kotlin/org/siloserver/silo/domain/player/SettlingFalseEdges.ktshared/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.
|
@coderabbitai review |
|
…ng-fill # Conflicts: # androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
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>
…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…
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:
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'sanimator_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
IntroAutoSkipControllergains aplaybackActiveinput 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 passestruethrough immediately, swallows a short stall, and surfaces a real pause after the grace period.Behavior examples
Summary by CodeRabbit
New Features
Bug Fixes