chore(sync): merge Silo-Server/silo-android main (238 commits) - #28
Merged
Conversation
…ar (Silo-Server#156) * fix(tv): keep the For You list selection, scroll, and a legible top bar Reported against v1.0.0+3 (83e23da), which already contains the recent TV focus batch — these are live on current code, not stale-release artifacts. **Watchlist and Favorites reverted to For You after opening an item.** `savedListSelection` was a plain `remember`, so opening an item disposed the composition and the value re-initialised from `entryRequest.selection` on the way back. Top-level For You entry carries `selection = null`, so returning from a Watchlist item did not merely forget the list — it actively reselected the recommendations feed. `lastAppliedEntrySequence` had to move with it. Left as `remember` it resets to 0, which makes the entry-request effect treat the unchanged request as new and re-apply its selection — reintroducing the same jump even once the selection itself is saved. Both are now `rememberSaveable`. **Scroll position was lost returning to the feed.** The recommendations `LazyColumn` created its list state inside the `when` branch, so a refresh that briefly flipped to loading/empty and back discarded it. Hoisted above the branch. **The top bar was unreadable over For You.** `TvTopMenuBar` deliberately has no background band of its own and documents that "the SHELL draws a fixed top scrim behind the bar" (QA 2026-07-08) — but the shell drew none, so the labels sat directly on whatever scrolled underneath. On For You that is a poster row. Restored as a gradient rather than a solid band, which satisfies both that contract and the shell's own intent that content stay visible behind the bar. This fixes every route, not just For You. Not addressed: the jerky scrolling on For You, and "cannot scroll the sections" after returning. The latter looks like focus restoration rather than scroll state (on a D-pad, no focus means no scrolling) and belongs with TvRecommendationsFocusBridge; both want their own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): hand focus back into For You after returning from an item Completes the second half of the report: "I can't scroll the For You sections anymore" after opening an item and coming back. The shell already has a detail-return path — a flag set when opening, a resume claim on the content group, and a restorer fallback pointing at the launch card — but every part of it was Home-only, and For You was wired to the raw onOpenItemDetail. Nothing claimed content focus on the way back, so focus settled wherever Compose's default search landed, and the D-pad no longer drove the rows the viewer was just in. Two flags now, deliberately: `restoreContentAfterDetail` means a return is pending for ANY root and gates the resume claim; `restoreHomeContentAfterDetail` additionally means it was the Home feed, which is the only root that attaches homeDetailReturnCardFocusRequester to its launch card. Using that requester as the restorer fallback for a root that never attached it would aim the restorer at a detached node, so Home's behaviour is left byte-identical and For You opts into the claim alone. The screen's own once-per-entry focus grab had to stop fighting it. Its guards were plain `remember`, so a detail return reset them, re-fired the effect and slammed focus onto the Watchlist pill while the feed sat scrolled where the viewer left it — the exact anti-pattern TvMainShell warns about: "fired LaunchedEffects in each screen that imperatively re-focused index 0 — defeating the restorer". Saved, the grab stays genuinely once-per-entry. Jerky scrolling is NOT addressed. The usual causes are ruled out — TvMediaRow carries key and contentType, hoists its row state, and memoises its item mapping on remember(items, showProgress, style, cardLayout) — so what remains (image decode during scroll, focus-driven recomposition, the absence of the skyline feed's settled-focus prefetch policy on this plain LazyColumn) needs a device profile rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): smooth For You focus scrolling * fix(tv): hide menu scrim on settings --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sync upstream into prairie-android with Silo→Prairie rebrand: settings contract/language catalogs, playback buffer architecture, specials-first season order, TV option dialog viewport fixes, For You list/top bar fixes, and related shared/TV/phone changes. Preserve Prairie-only Live TV DI bindings, X-Prairie-* + ImageFormats auth headers, American spelling in settings copy, and pinned Action SHAs. Move leftover org/siloserver paths to org.prairieserver.prairie and scrub brand tokens across the tree. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
* build(android): target API 36 * fix(player): adapt orientation lock for Android 16 * fix(tv): migrate back handling for Android 16
SharedModelsCoverageTest still expected specials last; sortedForDisplay now places specials before regular seasons (SeasonDisplayOrderTest). Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
Add focused unit tests for newly synced onboarding models/API, invitation auth routes, downloaded-subtitle URL rebasing, and QualityPresets edge cases so :shared:koverVerify clears the 95% line floor after the Silo sync. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
Aligns the shared overlay registry and badge renderer with
web/src/lib/overlays, closing drift found while auditing all clients
against the contract's card-overlays schema:
- standalone resolution badge now uses prettyResolution ("4K", not
"2160P"), matching the combined badge and web
- show_status recognizes "continuing", "upcoming"/"planned", and drops
empty strings
- HDR icon picking matches web's exact-match rules, and the combined
resolution_hdr badge only ever doubles up the Dolby Vision mark
- brand tokens (HDR/HDR10/ATMOS/AV1) suppress a label that says the
same thing, so icon-preferring presets don't render "HDR10 HDR10"
- original_language renders an English language name ("English"), not
a raw uppercased tag
Companion to the silo-apple fix for the Discord report of
/settings/card-overlays not applying on native clients.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-selection-ux fix(tv): improve Fire TV selection and episode continuity
…-contract fix(overlays): match web's card-badge rendering rules
…r#216) * fix(tv): observe focus arrival per target, not per screen requestFocusUntilObserved tests isFocused() BEFORE it ever calls requestFocus, so an arrival test broader than the thing being asked for turns the whole claim into a no-op. Five sites got this wrong, in two directions. Always-true — the claim never fires. Search and Requests both waited on a screen-root hasFocus, which is already true the moment you are on the screen at all: - Back from a result never returned to the search field, because a result having focus is exactly the state that satisfied the test; - a submitted search never handed you its results, because the field had focus; - return-restoration onto the card you left from was a coin flip on whether the root had reacquired focus yet. Always-false — the claim can never be confirmed. Calendar's calendarFilterHasFocus was declared and never assigned, so it read false forever: the claim burned every attempt and reported Exhausted even when focus had landed, and the reconfirm after Android's delayed focus pass, the bar-suppression release and onInitialContentFocus() never ran. Each claim is now observed on the region it actually asked for, so moving focus WITHIN a screen is a state the test can distinguish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): clear the calendar flag on exit, and guard search regions by item identity Two holes in the per-region observation. The calendar's zone callbacks only fire when a control or shelf GAINS focus, so moving Up from the filter into the top menu left the flag true with focus outside the screen entirely. A later shell handoff read that stale true, skipped both claims, and still reported initial content focus — and only another calendar zone gaining focus could clear it, which the skipped handoff could never cause. Clear it when the screen loses focus. Search's result-region callbacks are per card, and Compose can deliver the newly focused card before the outgoing one reports false, so the card that just lost focus cleared the region the new one had set; a recycled item has the same shape. Guard the clear on item identity, the way the neighbouring return-target tracking already does, so a stale false whose id is no longer current is ignored. Found by Codex review of Silo-Server#216. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): observe a search return on the card, not the region A return is a claim on one specific item, so testing 'some result has focus' is the same too-coarse arrival this change removed everywhere else: any already-focused card in the region satisfied it, and the saved card was never requested — precisely the case a return exists to serve. The identity wait below already knew the right answer; the claim above it did not. Found by Codex reviewing this branch against the new main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rver#218) The similar rail fetched inside its own LaunchedEffect, which only ran once the LazyColumn scrolled the row into view — so scrolling down always hit a pop-in. Move the fetch into ItemDetailViewModel (started from loadDetail, like the TV app's loadMoreLikeThis) and make SimilarRail a pure renderer fed from ui state. Code written by OpenAI Codex (gpt-5.6-sol) under Claude orchestration. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ci: cache Robolectric's Android runtimes Robolectric does not resolve its Android runtimes through Gradle. At test execution time it fetches android-all-instrumented straight from Maven Central into ~/.m2, which setup-gradle's cache does not cover, so every run re-downloaded them: 85 MB for API 24, 145-204 MB each for 34/35/36. On 2026-08-11 Maven Central answered 403 for one of those fetches and 31 unrelated tests in android-shared failed — sync, PiP, DB migration, downloads, pairing — on a pull request that touched none of it. The download time is also a large part of why this job takes 4-5 minutes when the tests themselves run in seconds. Keyed on the version catalogue so bumping Robolectric repopulates, with a restore-key so a bump seeds from the previous cache rather than starting cold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: track module SDK selection in the Robolectric cache key Which runtimes get fetched depends on the Robolectric version AND on the SDK each test resolves to, so hashing only the version catalogue could leave a newly-needed runtime outside the key — and actions/cache only saves on a key miss, so that one would be re-downloaded every run. Hash the module build files (min/target SDK) alongside the catalogue. A new @config(sdk=NN) in a test is deliberately not in the key: hashing test sources would bust the cache on nearly every pull request, which costs more than it saves, and the residual gap is one runtime until the key next moves. Raised by CodeRabbit on Silo-Server#217. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilo-Server#214) * fix(tv): make the player controls reachable, and Center mean pause Two things kept a viewer on the scrub bar from doing the obvious thing. D-pad Down is consumed by the activity-level remote-key bridge, which maps it to FocusTransport and asks the overlay to focus the transport row. That request is retried until observed — but the arrival test was `idleOverlayHasFocus`, hasFocus on the overlay ROOT, which is already true whenever any control has focus. With the scrub bar focused the retry loop concluded focus had arrived and never requested, so Down did nothing. It also explains the workaround: Back hides the controls, clearing the flag, so the next Down finally requested. Observe focus per control row and test the row actually asked for. Center on the bar entered a scrub mode. The Google TV remote has no play/pause key, so that spent the viewer's only one-press pause on something Left/Right already do (skip, and long-press auto-seek). Center now lands any scrub in flight and then toggles playback: racing forward it stops on the frame you asked for; hunting a spot while paused it plays on from it. Verified on a Google TV Streamer: scrub bar -> Down now focuses play/pause in one press. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): do not chain play/pause onto a scrub commit inside a room Center commits the in-flight scrub and then toggles playback. Solo that applies locally and in order, but in a Watch Together room they are two independently launched requests and the play/pause carries the LIVE position rather than the committed one — so it can land after the seek and pull every participant back to where the scrub started. Skip the toggle when a room owns transport; Center there commits, as it did before. The surface is compile-time disabled (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED = false), so this is latent rather than reachable, but the ordering hazard is real. Found by Codex review of Silo-Server#214. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add hosted diagnostics uploads * fix: harden hosted diagnostics delivery * fix: normalize hosted decoder identifiers * fix: redact hosted loopback identities * fix: preserve hosted erasure races * fix: close Android diagnostics privacy boundaries * fix: bound Android crash evidence retention * fix(diagnostics): harden hosted capture and delivery * fix(diagnostics): escape Android template regex * fix(diagnostics): preserve release metadata * fix: harden hosted diagnostics delivery * fix: close diagnostics review follow-ups
…ilo-Server#223) The rest-state border on the player transport controls is a 0.5dp translucent white stroke (22% alpha) drawn on each circular button. Because it is thinner than a device pixel and translucent, it dithers against the moving video and aliases into a jagged white fringe around every button, visible even on 4K panels where the sub-pixel stroke never lands on a whole device pixel. Drop the rest-state border and let the translucent fill carry the button edge. Focus treatment (white-fill inversion) is unchanged.
… server (Silo-Server#226) * feat(client-identity): report build number and release channel to the server The apps already sent X-Silo-Client and X-Silo-Client-Version on every API request, so the server could name the app but not the build. CI already computed a real build number in release.yml, but consumed it purely as arithmetic into versionCode — it never reached Gradle as its own value, so nothing on the device could report it. Threads the build number through as a first-class value: a validated siloBuildNumber Gradle provider (-PsiloBuildNumber, then SILO_BUILD_NUMBER, defaulting to "0") emits BuildConfig.BUILD_NUMBER in both app modules, and release.yml now passes it. The env block in the APK build step is appended to rather than restructured, because TvFireTvRcFeedbackOwnershipTest asserts its literal SILO_DISPLAY_VERSION line. Sends X-Silo-Client-Build and X-Silo-Client-Channel from the existing single header choke point, plus app_build/app_channel on the v3 playback context and the Cast prepare request. The build number is deliberately not derived from versionCode: that is the form-factor-doubled release code (base*2 phone, base*2+1 TV), not this counter, and reversing the formula would be fragile. A build CI never stamped reports as absent rather than as build 0 — the server treats the value as an opaque string, so a placeholder would surface verbatim as "(build 0)" in admin Activity, and channel=dev already carries that meaning. normalizedClientBuildNumber is the single place that knows it, reused by all three carriers and the About row. Also normalizes two device-login platform spellings to "android-tv": RemotePlaybackIdentityManager sent "android_tv", which the web frontend's classifyPlatform bucketed as mobile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(client-identity): one source of truth for build number and channel Review follow-up on the build-number reporting. Depth. The build number and channel were threaded through five playback call sites, so the one shared caller that cannot see BuildConfig — the audiobook player — reported neither, leaving audio sessions exactly as indistinguishable as before. Both facts now cross into android-shared once as a DI-provided SiloClientBuildIdentity, which the metadata provider, the capability detector, the Cast request and the diagnostics environment all resolve. The five video call sites revert to their original form; the audiobook one is fixed without being touched. Channel. Play bundles and sideload APKs are both assembled from the release build type, so `if (BuildConfig.DEBUG)` reported "release" for both and the field said nothing. It is now BuildConfig.RELEASE_CHANNEL, set per build type off the existing isBuildingBundle signal: bundle -> release, assemble -> sideload, debug -> dev. Verified against generated sources. app_build. Diagnostics already sent that field holding the versionCode, so one install reported two different builds under one name. It now sends the same counter, matching silo-apple's CFBundleVersion on all three carriers. Platform. The TV had a third device-login spelling, "Android TV", on the LAN companion-pairing path, which classifyPlatform buckets as mobile. Also: conformance fixtures now carry app_build/app_channel with tests pinning the encoded key names and the omit-when-unstamped rule, since the round-trip check alone would not catch a name mismatch; build number bounded to 0..999 as release.yml and the Fastfile do; the About row label is one shared helper in the "1.0.0 (5)" form the server and Play both render; unread DISPLAY_VERSION dropped from androidApp. ./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug test PASS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(client-identity): stamp the real Play track and keep prereleases distinct Two review findings on the channel and build number. Channel. Deriving it from the invoked task labelled every bundle "release", but the Fastfile uploads to internal/alpha/beta/production, so a beta tester and a production user reported the same thing — the attribution the field exists to provide. It is now an explicit siloReleaseChannel property that the Fastfile fills with the track it is actually uploading to, validated against Play's vocabulary plus sideload/dev so a typo cannot reach the server as a header. The sideload APK job states its channel outright; a hand-built artifact defaults to sideload, which is what it is. Prerelease identity. A -rc.N tag with no +N suffix resolved to build 1, so v1.2.3-rc.1 and v1.2.3-rc.2 both reported version 1.2.3 / build 1 and stayed indistinguishable — precisely what the build number was added to fix. The counter now comes from the suffix (-rc.2 is build 2), canonicalized so -rc.02 and +02 agree, with a numberless suffix still meaning build 1. Verified by executing the workflow's own setup block over each tag form: v1.2.3 -> 1, v1.2.3+2 -> 2, -rc.1 -> 1, -rc.2 -> 2, -rc.10 -> 10, -rc.02 -> 2, -beta -> 1; -rc.1000 and +0 rejected by the existing bound; play_publish still false for every prerelease. Channel verified against generated sources: debug -> dev, assembleRelease -> sideload, -PsiloReleaseChannel=beta -> beta, nightly -> rejected. ./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug test PASS scripts/test-release-workflow.sh, test-check-build-supply-chain.sh, check-build-supply-chain.sh PASS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * revert: do not derive the build counter from a prerelease suffix Reverts the -rc.N -> build mapping from 583fa15. Bugbot was right and the change was a net regression. The counter is folded into the versionCode, so mapping -rc.2 to build 2 gave the sideloaded prerelease code base+2 while the official v1.2.3 gets base+1. Android refuses the lower code, so a QA device on rc.2 could no longer take the official release of the same version — an upgrade path that worked before, when both resolved to base+1 and installed as a reinstall. It did not even buy what it was meant to: -rc.2 and +2 resolve to the same counter, so those two artifacts still reported an identical version/build/channel triple. Confirmed by executing the workflow's setup block before and after: with the mapping, v1.2.3-rc.2 -> 110203002 against v1.2.3 -> 110203001; after the revert both are 110203001 and every tag form matches main's behaviour. Prerelease artifacts stay distinguishable by their tag and GitHub release but not by reported identity. Fixing that properly means carrying the prerelease suffix in the reported version rather than the counter, which changes app_version semantics for suffixed tags and is a call for the release owner, not a second unilateral guess at the versioning scheme. ./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug test PASS release-workflow / supply-chain self-tests PASS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(client-identity): align fixture channel with the configured vocabulary The v3 golden fixtures asserted app_channel "release", which stopped being a value any artifact can emit when the channel became the Play track. Both request fixtures and the conformance assertions now use "production", so the corpus matches what a real build reports. Also renames siloReleaseChannels in both build scripts to match the camelCase every other val in those files uses. ./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug test PASS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…er#227) * Update launcher icons and TV art to the new vector artwork Re-render the phone and TV launcher icons, the adaptive foregrounds, both silo_wordmark drawables and the TV banner from the SVG masters in Silo-Server/silo-branding. The adaptive foreground was a fully opaque copy of the whole tile, which hid the background layer entirely and put the mark well outside the 66/108 keyline, so circular and squircle launchers were cropping the top of the play triangle and the bottom of the orange bar. The foreground is now transparent art sized to the keyline circle, which makes silo_icon_background visible for the first time; it moves from #1718C9 to the brand field #010D9F so the two layers agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012iRtwoxswjkgRqcgAeczVP * Match the guarded TV launcher icon geometry androidTvApp already had a correct mark-only adaptive foreground and a legacy icon that insets the mark on an opaque field, both guarded by TvLauncherIconAssetsTest. The first pass would have regressed them: it centred the mark on its minimum enclosing circle, which sits near the mark's left edge and so pushed the artwork right of centre, and it rendered the legacy icons from the rounded project icon, which has transparent corners. Size the safe zone from the largest radius about the bounding-box centre, and render the legacy TV icon as the flat mark inset on an opaque field at the 217/320 proportion the hand-made asset used. The regenerated foreground lands at 80x255 +176+88 against the hand-made 79x255 +176+88. The phone app gets the same treatment; its foreground was still the baked opaque tile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012iRtwoxswjkgRqcgAeczVP --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…den + reconnect verbiage change (Silo-Server#222) * fix(tv): show one buffering indicator, not two PlayerView's built-in centered spinner was still enabled alongside the top-right "Buffering" capsule that had been added to replace it, so any rebuffer drew both at once: a teal spinner mid-screen plus the capsule. The capsule landed in 2454-2546 with a comment saying it replaced the full-screen spinner, but setShowBuffering was never turned off. Behavior change: during playback buffering the centered ExoPlayer spinner no longer appears; the "Buffering" capsule is the sole indicator. The centered white Compose spinner is untouched and stays reserved for the lifecycle Reconnecting state, which the player cannot observe. * fix(tv): report buffering while the controls are hidden The "Buffering" capsule and the sleep-timer chip lived inside TvPlayerIdleOverlay, which is only composed while state.showControls is true. With the controls auto-hidden, D-pad Left/Right does a discrete seek (dpadHorizontalSeek gates on !showControls) without revealing the transport, so the rebuffer that seek triggers had no indicator at all once PlayerView's spinner was off. Moved both chips up into TvPlayerOverlays, which renders regardless of controls visibility, using the same precedent as the intro auto-skip banner and the transient skip-seek indicator. Rewrote the block's .align(TopEnd) as a fillMaxSize Box with contentAlignment, since TvPlayerOverlays' body is not a BoxScope. Both chips moved together; they share one Column at the same TopEnd offset, so splitting them would overlap. Behavior change: buffering and the sleep countdown now appear during hidden-controls seeks and hold-to-seek sessions, not just when the transport is up. Gated off in PiP, while the HUD is open, and while Up Next is showing, all of which provide their own feedback. When the controls are visible the result is unchanged. No collision with the Watch Together room indicator (also TopEnd, but still controls-gated) or the hold-seek chip (TopCenter). * fix(tv): let the reconnect spinner outrank the buffering capsule A comment on the outage spinner claimed it only showed when the idle overlay wasn't already showing the Buffering chip, but shouldShowReconnectSpinner never checked that, and a player stalled by a server outage reports STATE_BUFFERING like any other stall. Reconnecting therefore drew the centered white spinner and the capsule at once. That already needed controls-visible to happen; moving the capsule out of the idle overlay widened it to every Reconnecting stall. Behavior change: the Buffering capsule is suppressed while the centered reconnect spinner is up. During a server outage the screen shows the centered spinner plus the "Reconnecting" notice toast, which say strictly more than "Buffering" does. Ordinary rebuffers are unaffected. The sleep countdown chip is not gated on this; it is unrelated to either signal. Also corrects the outage-spinner comment, which still described the capsule as living inside the idle overlay's statusColumn. * fix(player): stop blaming the reconnect notice on a server update beginOutageRecovery fires on isGatewayOrTunnelFailureStatus (502, 503, 504, 520-527, 530) and on NetworkError, so the cause can be a crashed server, a restarting one, a dead tunnel or reverse proxy, or the client's own network dropping. The notice asserted one specific cause, "The server is updating", which is usually wrong and reads as a false explanation to anyone debugging their own setup. Its paired timeout message was already cause-agnostic, so this was the odd one out. Behavior change: the reconnect pill now reads "Reconnecting. Playback will resume automatically." No claim about why, and no promise about server readiness that the client cannot verify. Same wording on phone and TV, since both consume this constant from the shared lifecycle. Note this now differs from the Apple clients' copy, which lives outside this repo. * fix(tv): keep the buffering capsule up wherever video is playing The capsule replaced PlayerView's spinner (SHOW_BUFFERING_NEVER), but it was gated on !hudOpen && !showNextUp, so those two surfaces had no buffering feedback at all. Neither owns a loading state: a HUD quality or version pick restarts the whole session with the HUD still open (closeOnSelect closes only the picker), and Up Next plays video behind the mini-player frame until the credits end. Keep the capsule up on both, dropping it below the HUD card so they don't overlap, and let the ambient sleep-timer chip keep yielding the corner. Gate the block on videoActive as well: it moved out of the streamUrl branch into TvPlayerOverlays, and fail() sets error without clearing isBuffering, so a spinning capsule could land on the error screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
) The splash was a 4K h264 file with no source alongside it, and its artwork predated the vector rebuild — its play triangle used a deeper blue than its first bar, which the palette no longer has. This is the same animation regenerated from silo-wordmark-white.svg, so it tracks the masters and matches the launcher icons and TV art updated in Silo-Server#227. Motion is unchanged: timings were measured frame-by-frame off the previous file, and every element holds within 20px at 4K, or 1.3% of the mark's height. The triangle now reads brighter, being the palette's signal blue, and the new lockup's mark-to-text gap is fractionally tighter — about 48px at 4K, under a pixel and a half at phone size. Same container: 3840x2160, 60fps, 4.0s, h264. 2.9MB down to 490KB, because flat vector colour compresses far better than the previous render. StartupSplashVideo keeps loading it as R.raw.startup_splash. The Lottie cut is the same animation as vector, at an eighth of the size. It is named startup_splash_lottie.json rather than startup_splash.json because res/raw names resources by filename without extension, so the latter would collide with the video on R.raw.startup_splash — aapt2 rejects it with "resource 'raw/startup_splash' has a conflicting value". Nothing loads it yet. Generated by src/splash.py in the silo-branding repo. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…sted allowlist (Silo-Server#232) * feat(diagnostics): re-vendor expanded attribute registry and align hosted allowlist Picks up the attribute keys added to the canonical registry in silo-server, keeping this client's copy in lockstep with the collector. Also fixes three gaps found while coordinating this change with the Apple client: - REGISTERED_ATTRIBUTES in DiagnosticsValidation.kt had fallen behind the vendored fixture. Unknown keys hit a `null -> Unit` branch, so they lost type validation silently rather than being rejected. - Nothing in the test suite read the vendored fixture, so this client had no equivalent of the Go and Swift parity gates and could drift by hand again. Adds DiagnosticsAttributeRegistryParityTest. - HOSTED_V1_LOG_ATTRIBUTES forwarded playback session_id, play_method, reason, and position_ms plus network attempt to the hosted collector. session_id and attempt are in the collector's FORBIDDEN_KEYS, so those bundles would fail the privacy_fields check; the Apple client withholds the same five keys. Now aligned, with a test pinning the withheld set in both directions. lifecycle.reason is a client-side classification and is deliberately still forwarded -- only playback.reason, which is server-authored free text, is withheld. No production Android code emits these keys yet, so the allowlist change is a latent-leak fix rather than a behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(diagnostics): update vendored registry provenance Points SOURCE at the silo-server commit these registry additions came from, so the vendored fixture's stated origin matches its contents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…endering overhaul (Silo-Server#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…
* fix(diagnostics): preserve safe hosted crash frames Co-Authored-By: OpenAI Codex (GPT-5) <noreply@openai.com> * fix(diagnostics): retain Java module stack frames * fix(diagnostics): harden crash stack salvage * fix(diagnostics): bound crash excerpts by line --------- Co-authored-by: OpenAI Codex (GPT-5) <noreply@openai.com>
Accept the verified Gradle Plugin Portal checksum for the Kotlin Multiplatform 2.1.20 marker POM while preserving strict dependency verification.
…ver#237) * fix(browse): stop the library sort resetting to its default TV: backing out of item detail returned to a surviving library ViewModel, but re-entering the screen re-issues the committed cascade section, and onTabSelected re-applied the tab's default filter whenever the viewer had customised it — the guard only skipped when the filter was ALSO unchanged. Re-selecting the already-active tab is now a no-op; only a genuine tab change applies the new tab's defaults. Phone: the browse sort lived outside the persisted CatalogFilterState, so "Preserve sort & filters" restored the facet chips while the sort snapped back to Recently Added on relaunch. The sort now rides the persisted filter state (as BrowseViewModel already does) and is derived back out on restore. applyFilterState keeps the committed sort so the Reset control, which changes the sort and clears facets off the same composition frame, cannot reinstate the old one. Verified on both emulators: TV keeps Year/Newest across Back, phone keeps Title across a force-stop relaunch, and Reset still clears both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tv): correct the section-apply comments Both comments asserted the behaviour the guard change removes — re-committing the same pill "re-applies the section rather than being a silent no-op". That is now exactly what must not happen, and these are the comments the next person reads when debugging this area. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: enhance Android system media controls * fix: defer remote media service starts to foreground * fix: keep artwork lookup off playback path
Bring prairie-android up to date with upstream silo-android since the prior sync branch, including playback/auth/diagnostics/TV focus work and related fixes. Prairie adaptations: - Remap org.siloserver.silo → org.prairieserver.prairie and Silo* → Prairie* - Keep Live TV DI bindings and X-Prairie-* / ImageFormats headers - Keep Kover coverage gate alongside upstream Robolectric cache + lint jobs - Follow upstream removal of full admin management surfaces Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
Rename leftover skipSiloAuth / SharedPrefsSilo* / cast helpers and user-facing Silo product strings so the tree compiles under the Prairie namespace after taking upstream main. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
|
Important Review skippedToo many files! This PR contains 910 files, which is 810 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (27)
📒 Files selected for processing (910)
You can disable this status message by setting the |
Admin management code (including the STATS dashboard) was removed with the silo-android sync; keep README/FEATURES consistent with AGENTS.md. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
The silo-android merge dropped the kover alias from libs.versions.toml while Prairie kept the :shared coverage gate in build.gradle.kts and CI. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
The upstream merge left SkipSiloAuthAttributeKey references in AuthInterceptorImpl and dropped HealthApi.checkHealth(serverUrl) that LanDiscovery depends on. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
The sync merge left adaptive launcher XML pointing at @color/prairie_icon_background while colors.xml still defined silo_icon_background from upstream. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Prairie-android was 406 commits behind
Silo-Server/silo-androidmain(238 beyond the stale draft sync oncursor/sync-silo-android-e10e/ PR #27).Approach
Merged remaining upstream
mainonto the prior sync branch tip, then rebranded:org.siloserver.silo→org.prairieserver.prairieandSilo*→Prairie*X-Prairie-*/ImageFormatsheadersskipSiloAuth/ cast helper renames for compileAfter merge: 0 behind
upstream/main.Supersedes draft PR #27.
Testing
Android SDK / full Gradle not run in this environment — rely on CI.
AI Disclosure
Checklist