Skip to content

Post-refactor features: animations, Showcase metadata, trailer, hover grow, transitions, failover chain, appearance preview - #265

Draft
ghbarker wants to merge 84 commits into
mainfrom
feature/post-refactor
Draft

ghbarker wants to merge 84 commits into
mainfrom
feature/post-refactor

Conversation

@ghbarker

@ghbarker ghbarker commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Why

Integration PR for the post-refactor feature lanes. Opened as a draft so CI runs on the integrated tree (pushes to feature branches do not trigger the Test workflow). Merge to main only on explicit go.

What (each lane was reviewed and CI-green individually)

How verified

Each lane PR: targeted tests + fork CI (test / goldens / native-player) green on its head. This PR's CI is the integration check.

ghbarker and others added 30 commits September 7, 2026 16:07
The hide_watched_titles switch filtered home rows and search but never the
detail-page recommendation rails, so a finished title kept resurfacing under
every related page. Both recommendation loaders now run their result through
WatchedFilter.apply — single-shot at load, matching the home rows and search
(no re-apply when the watched snapshot lands later).

WatchedStatusService gains two @VisibleForTesting hooks so a test can publish a
local snapshot without starting tracker fetches; the new test pins that a
watched IMDb id is dropped from the rendered rail with the switch on and kept
with it off.
Band order was identity → episodes → cast → guide → sources → recs. The actors
now sit immediately above the titles they lead you to: identity → episodes →
guide → sources → cast → recs. The move is made in both ordered structures —
_bands (the DPAD ladder) and _pageBody (the rendering) — so TV focus topology
and the reveal choreography still match what is drawn. ShowcaseCast itself is
untouched.

Tests that stepped the ladder by count are updated to the new order, and a new
one pins Sources above Cast above Recs in layout and that DOWN walks
sources → cast → recs.
The wide Showcase identity fetched rating, certificate, runtime, genres
and the plot, then rendered them so faintly that a page with a full IMDb
enrichment read as having no metadata: two genres in a 10.5pt meta line,
a 7.5pt rating box, the certificate as a word in a 9.5pt tech line, and
three lines of 10.5pt plot in a 410-wide column.

Wide (desktop + TV) identity now shows:
- a score row: filled IMDb rating pill ("* 9.5" with the vote count
  muted beside it), a METACRITIC chip when the score exists, and the
  certificate as an outlined badge with real weight, then the tracker
  marks;
- a facts line: year . runtime . every genre (not genres.take(2));
- the synopsis at 12pt / 1.45 line height in a 600-wide column, clamped
  to three lines with MORE/LESS expanding in place on pointer surfaces.
  On TV the plot stays clamped and no MORE is rendered: a gesture-only
  control is inert under a DPAD and the layout counts this band's focus
  nodes.

The band's height is now a minimum on both tiers (compact already was),
so opening the synopsis or enlarged text grows the band instead of
overflowing; when the block fits, a bottom-aligned min-height box lays
out exactly as the fixed box did.

_ExpandableSynopsis takes lines/style/alignment and can withhold MORE
when the collapsed text is not actually clipped; the compact path keeps
its defaults and is visually unchanged. Tracker marks are extracted into
one helper shared by both tiers. DetailModel gains voteCount and
metacritic getters.
… per tab

Opt-in (enabled=false by default) click-time failover chain policy: provider
order (defaults to CloudProviderId.playbackPrecedence), sibling / later-provider
caps, resolution-match mode, Range probe toggle + timeout, never-probe provider
set (premiumize, pikpak), demotion window. Clamped, JSON round-trip, ==/hash,
copyWith.

Persisted inside the existing per-tab quick_play_*_rules_v2 JSON, emitted only
when customized, so default profiles serialize byte-identically, no new pref
keys are introduced (owned keys and the frozen origin-restore manifests are
unchanged), and profiles saved before the field decode to the default.
The Trailer button promoted the backdrop's player in place only when frames
were already on screen (canPromote). Every other state -- the ambient engine
still buffering, autoplay off, the per-visit stop after content playback --
resolved the stream again and pushed the standalone player, so the trailer
"played but never focused" on the page.

play() now parks on the backdrop instead: HeroTrailerBackdropState gains
whenPromotable() (true on the first rendered frame, false on a non-restart
teardown / dispose / timeout) and requestForegroundStart() (lifts the
per-visit playback latches, schedules a start when a URL is already held, and
refuses only under reduced motion or a native underlay engine). The
controller exposes foregroundRequested, and the host feeds the backdrop its
stream and keeps it enabled while that holds -- regardless of the autoplay
setting or Showcase depth -- so a press with autoplay off starts the same
surface, then promotes it. The intro skip is suppressed when a promotion is
pending so an explicit watch starts at the top. The standalone player remains
only for TV, reduced motion, a missing backdrop, or an engine that fails.

Tests drive the real controller + backdrop pair through a host wired like the
detail screen, with a scripted engine: buffering press promotes on first
frame; autoplay-off press starts the engine then promotes; closing restores
the ambient loop (or tears down with autoplay off); TV paths unchanged.
… demotion view

FailoverChain.build(candidates, clickedIndex, policy, resolverProvider,
recentlyServed) returns the ordered source indices to attempt: the clicked row,
same-provider siblings by nearest resolution (equal, nearest below, nearest
above) capped at maxSiblings, then later providers in the user's order (best
match first, capped per provider), with recently-served links moved to the end.
enabled=false returns the legacy linear walk from the clicked row so the player
can drive one loop either way.

A row's debrid provider: torrent rows share the session resolver
(startupResolverProvider); direct rows are inferred from the URL
(/realdebrid/, torbox, premiumize, alldebrid|debrid.it, pikpak) then the addon
label ([RD+], TB, PM, AD, PP), else 'direct' (sorted after every listed one).

Resolution parsing extends lib/utils/source_quality.dart with the reference
regex (sourceResolutionForName) instead of duplicating it.

RecentlyServedLinks is the read-only demotion view; the bounded store lands in
a later step. Exhaustive unit tests mirror the reference cases.
Poster tiles grew by four different literals (1.05/1.05/1.08) on four
literal durations (150/160/180ms), none reading AppMotion, so neither a
theme's tempo nor reduced motion reached the one animation a desktop user
sees most — and on Windows the grow was too small to register.

- FocusTokens.hoverScale (1.12) + hoverScaleFor(isTv), which always
  returns FocusTokens.tvHoverScale (1.045) on a television.
- HoverGrow (lib/theme/widgets): reads the figure and AppMotion in build,
  scales as a transform, and vends durationFor() so a site's shadow and
  ring stay in lockstep — motion.base off TV, Duration.zero or motion.fast
  (the rise's shipped 120ms) on TV.
- Consumed by CatalogItemTile (classic chrome; image fades and the
  ensureVisible glide go through motion.scaled), CardFocusRise,
  CatalogDetailRecCard, and showcase _Poster/_CastTile under non-parallax
  looks only (disabled where ParallaxFocus owns the lift).
- Discover stage shelf list: Clip.hardEdge -> Clip.none so the 1.12 grow
  is not shaved by the 20px slack.
- Pins in test/hover_grow_motion_test.dart; rec card pin updated.
StreamUrlValidator.isAliveByRangeProbe(url, timeout, headers): one GET with
Range: bytes=0-0, alive iff 200/206. One retry on timeout (the reference's
rule), any other status or a connection/TLS/DNS error is dead on the first
answer. Redirects follow with the client; the response body is cancelled
immediately so a host that ignores the range never streams the file.

Kept separate from the HEAD validator on purpose: no size floor, no HLS
carve-out, no lenient mode - the chain already holds its next candidate.
Tests run against a real loopback HttpServer (206/200 alive, 4xx/5xx dead,
redirect, stall-then-answer retry, double stall, refused connection, header
passthrough).
Route motion was only overridden for Android (TV fade, phone zoom); iOS
and macOS ran the Cupertino slide, Windows/Linux fell through to the
Flutter zoom default, and Apple TV never received the TV fade at all.

AppPageTransitionsBuilder (lib/theme/app_page_transitions.dart) is now
registered under every TargetPlatform and makes the choice in one place:

- TV (Android TV / tvOS): the fast fade, unchanged.
- Reduced motion: the same fast fade, no scale or slide. A
  PageTransitionsBuilder cannot shorten the route controller, so a
  trivial transition is the only lever; checked before the platform so
  an iPhone with Reduce Motion skips the Cupertino parallax too.
- iPhone/iPad: the stock Cupertino builder, kept deliberately so the
  interactive swipe-back gesture (private to the framework) survives.
- Everything else: SharedAxisTransition from the already-declared
  `animations` package - scaled (drill-in) for ordinary routes, vertical
  for fullscreenDialog routes - filling through scaffoldBackgroundColor
  so the exiting page fades to the theme ground and both push and pop
  animate the secondary page.

No AppMotion curves: buildTransitions runs inside the route's per-frame
AnimatedBuilder, where theme-token reads are forbidden, and the shared
axis owns its choreography. The AppMotion note now points here.

Tests: test/theme/app_page_transitions_test.dart pins platform
coverage, the TV fade window, the reduced-motion collapse, the iPhone
Cupertino path, scaled/vertical selection, the fill colour and the
exiting page's transition on push and pop.
RecentlyServedLinkStore (1000 entries, oldest evicted) keyed by title/episode
identity (IMDb id else title, plus SxxEyy) -> link key -> served-at. record()
on serve, markPlayed() when the startup decoder gate accepts a candidate,
wasServedWithin()/viewFor() feed FailoverChain.build's RecentlyServedLinks.

Replaces the reference's time-based sticky-repeat heuristic with the app's
direct signal: a link the decoder accepted is never demoted; every
attempted-but-rejected link stays demoted for the window. Clock is injectable
for tests.
…egory

Appearance now opens with a preview of the current options drawn on a real
mini-screen (hero + scrim, poster shelf with the focus cursor, accent
button, sheet) and a strip of Look chips under it. Hovering or DPAD-
focusing a chip previews that Look without applying it; tap / OK applies
it the way the Looks page does. The applied state follows
AppThemeController and TextBrightnessController, so opener pages (Looks,
Advanced, Text Brightness) show up on the card the moment they change.

The preview is resolved purely (resolveAppearancePreview mirrors the
controller's derivation) and never through the controller. The strip is
ONE pane node on TV — Left/Right inside it, Down to the Presets rows —
counted by tvFocusableCount so the pool covers it; the ACTIVE LOOK hero is
retired and `looks` becomes a plain Presets row on every surface.
…InitialVodWithFailover

Local to _openInitialVodWithFailover only. The candidate loop now walks a
precomputed index list: with the policy disabled (default) that list is the
legacy linear ladder from the ranked start and the attempts bound is checked at
the same point, so the existing path is behaviourally unchanged. With the
policy enabled the list is FailoverChain.build over _effectiveSources from the
clicked row (session resolverProvider = startupResolverProvider), with the
title/episode-scoped RecentlyServedLinkStore view; an already-failed initial
attempt drops the clicked row wherever the chain placed it.

Caps vs 'Streams to try': the chain decides ORDER (its own caps bound how many
candidates exist); maxAttempts still bounds total decoder attempts. A
probe-dead candidate never reached the decoder and does not spend a slot.

Probe: GET Range bytes=0-0 on the resolved URL before the existing openers,
skipped for the policy's never-probe providers and for AIOStreams-class links
(same rule as the HEAD preflight). Dead links and every link handed to the
decoder are recorded; the accepted one is marked played, so only
evidently-unplayed links get demoted on a re-click.
…order

Detail page: hide-watched reaches More Like This; Showcase cast sits above recs
Showcase: legible metadata block on the desktop/TV identity band
…s, tests

A 'Failover chain' section (per Movies/Series tab, after Addon priority):
opt-in switch; when on, a reorderable debrid provider order (same pick-up/drop
DPAD grammar as Addon priority, arrows on desktop), same-service and
per-later-service caps (0-10), resolution match select, probe switch with
timeout select and per-provider never-probe switches, and the demotion window
select. Restore defaults collapses it. _PriorityRow gains an optional tag so
the rows read 'Debrid service'.

Search leaves for every knob. Page tests cover collapse/expand, per-tab
persistence of every knob, arrow reorder and restore; the DPAD test walk gains
the switch before Restore defaults, plus a walk over every knob and a
pick/move/drop of a provider row.
Under a premium look CardFocusRise hands the cursor to FocusExpressionBox,
and for ring, underline, invert, flood and lift that box drew its
decoration and moved nothing — so on a desktop the "hovered box gets
bigger" feedback existed only on the legacy theme and the classic grid.

- FocusExpressionBox.grow (default false): a poster tile/card with its own
  gap to grow into. When set, the box wraps its body in HoverGrow on the
  shared FocusTokens.hoverScaleFor figure (1.12 pointer / 1.045 TV, snap on
  TV like the rest of the cursor) on top of whatever the expression draws.
  For scale and lift the tile figure REPLACES the small cursor scale
  (1.06 / 1.02) rather than stacking on it — one transform per tile.
  Parallax is untouched; ParallaxFocus owns that lift at its own peak.
  Off (the default) nothing changes: settings rows and pills keep 1.06.
- CardFocusRise: grow: true on its FocusExpressionBox; the outer legacy
  HoverGrow is now `enabled: ownCursor`, so every card carries exactly one
  scale transform whichever path it takes. Covers the home board cells,
  the favourites strip and CatalogItemTile's board chrome.
- SpotlightBoard's _Card: grow: true off parallax, same reasoning.
- test/hover_grow_motion_test.dart: +11 pins — each non-scaling expression
  grows the board card 1.12 with its cursor still drawn, scale/parallax
  carry exactly one transform, keyboard focus takes the pointer's path,
  reduced motion and TV policies hold, and the non-tile cursor is unchanged.
Detail trailer: the Trailer press always ends fullscreen in-app off-TV
Page transitions: one shared-axis builder for every platform
The PNG is Windows-rendered like every other committed golden; on GitHub ubuntu it
rasterises differently and fails the goldens job, exactly as the existing
settings_tv_spotlight_layout golden does. Reproduced in the PR's goldens run.
… a raw NUL byte

Two literal NUL bytes (one in the doc comment, one in the key string literal) made
git and every text tool treat the Dart source as binary. The escape has the same
runtime value; behaviour and tests are unchanged.
Hover grow: one shared tile scale on the theme's motion tokens
StreamBadgeGroup.color and StreamBadgeRule.tagColor/textColor/borderColor
are now ARGB ints; parseBadgeColor/encodeBadgeColor work on ints. The
badges.json wire format is unchanged. StreamBadgeChip wraps the ints in
Color at paint time, so lib/models/stream_badge_rules.dart no longer
imports dart:ui.
TransferCategory.icon (IconData) becomes TransferCategory.glyph, a
TransferCategoryGlyph enum defined in the service; the Material mapping
lives in lib/widgets/transfer/transfer_category_chrome.dart as an
extension that restores the .icon getter for widget callers. Colour stays
a dart:ui Color. transfer_category.dart and transfer_categories.dart no
longer import package:flutter/material.dart.
Appearance: live preview card with a Look strip at the top of the category
…idgets

Add lib/services/cloud/cloud_provider_presentation.dart with the pure
provider data (label, chip code, gradient, catalog chip/title, playlist
badge; dart:ui Color only). CloudProviderChrome (widgets) delegates to it
and keeps the Material icon and the bind-source chip. TorrentPlaybackService
imports the presentation instead of ../widgets/cloud_provider_chrome.dart;
the two widgets it hands an icon to (ProviderPickerOption,
showDebridActionSheet) now resolve the glyph from the provider id
themselves when no IconData is given, so the service passes none.
Failover chain: user-configurable click-time stream failover (opt-in)
…porting the screen

CloudBrowseSelectSource has to import the cloud browser screens, so it
cannot move into lib/widgets. Add lib/widgets/cloud/cloud_select_source_opener.dart,
an interface with push / pushRdOrTorbox, implemented by
CloudBrowseSelectSource.opener. AggregatedSearchResults and TraktResultsView
accept an optional cloudSelectSourceOpener from their hosting screen; the
add-source picker hides the cloud bind options when none is supplied.
Neither widget imports lib/screens/cloud any more.
layering: remove the six fork-introduced import violations (52 -> 46)
… pill

Replace Colors.white.withValues() calls in _DetailHoldHintPill with DetailTheme
tokens (t.hair for border, t.tx for text/icon) so the guard passes on light
themes like Broadsheet and Concrete.
ghbarker and others added 30 commits September 7, 2026 20:39
Cast tiles open the actor's known-for titles
…able

After the shared TV focus tempo landed at 120ms (PR #272), the verdict from
the Shield was "everything just feels so snappy instead of smooth" — and
earlier, "my settings may not be for everyone". This introduces
TvMotionProfile { smooth, snappy } so a television's motion is a choice,
not one hard-coded tempo.

Storage: services/tv_motion_profile.dart adds the pref (tv_motion_profile,
owned/sanitised/copyable like the other style prefs) and TvMotionController,
a synchronous ValueNotifier warmed in main() before runApp (like
AppThemeController). Unset resolves to a per-device default: smooth on tvOS
and on Android TV boxes reporting the NVIDIA Shield (manufacturer, model,
board or device name), snappy on every other Android TV. Off television the
profile is unread.

Propagation: TvMotionScope (theme/tv_motion_scope.dart), an InheritedTheme
installed beside AppThemeScope in MaterialApp.builder via a
ValueListenableBuilder on the controller's notifier, so a chip press
retargets every focus tween on the spot.

Tokens: AppMotion gains `profile`, `tvFocusCurve`, `tvScroll`/`tvScrollCurve`,
`scrollTempo`, `tvEntrance`/`entrance`, and `AppMotion.tvRoutesSharedAxis`.
Snappy keeps every shipped figure byte-exact (fast focus, zero scroll-follow,
the fast route fade, entrance none). Smooth is `base` (220ms) with the
`emphasized` curve on TRANSFORM sites only (AnimatedScale — the ring/fill/
shadow tweens beside them stay on `standard`, since an overshooting curve on
a lerped BoxDecoration or a clipping size asserts), a ~260ms scroll-follow,
the shared-axis route transition, and the theme's entrance choreography.
Reduced motion collapses both profiles to zero/none, and the TV raster
policies (2.5px ring floor, no big blurs) are unchanged either way.

Routed every TV Duration.zero ensureVisible call through
AppMotion.tvScroll/scrollTempo: catalog_item_tile, episode_tile,
tv_sidebar_nav, home_trakt_now_playing_card, youtube_video_card,
iptv_channel_row, iptv_epg_panel, detail_identity/detail_layout_premium/
detail_layout_stage, export_channels_dialog, iptv_settings_two_pane,
sidebar_customization_page, stremio_tv_tuner. Routed the curve on every
TV AnimatedScale site through AppMotion.focusCurve: focus_expression,
hover_grow, parallax_focus, detail_action_buttons, episode_tile, and the
debrify_tv compact/focusable buttons. app_page_transitions now falls
through to the shared-axis transition on TV under the smooth profile.
section_reveal reads AppMotion.entrance instead of the raw token so a
television's entrance choreography follows the profile too.

Settings: Appearance -> Display -> "TV motion" (TV surface only), two
inline chips via the existing SettingsRowKind.options / SettingsOptionRow
machinery the Screen-layouts rows use, plus a search leaf per chip since
the row has no page to open. Bumped
SettingsTvLayout._kMaxCategoryRows to 21 to keep that pin honest with the
one extra row.

Tests: theme/tv_motion_profile_test.dart pins the device default, the pref,
the controller and every AppMotion figure under both profiles, plus
TvMotionScope propagation. settings_tv_motion_row_test.dart covers the
catalog wiring: exactly one pref key on choice, TV Left/Right/Down, pane
node release, and a chip press rebuilding a live AppMotion.tvFocus
consumer. Ran and confirmed unchanged: shape_type_motion_test (TV policy
pins), hover_grow_motion_test, focus_expression_motion_test,
app_page_transitions_test, settings_appearance_groups_test,
settings_layout_options_test, source_guard_test, storage_key_sweep_test,
detail_showcase_test (cast-tap-to-actor feature untouched).
…n rails

Rows layout now opens straight into Home-style rails: no hero band, no
Folder/View/Sort dropdowns. Folder switching stays on Home (every folder is
already its own tile in the collection's row); a showAllTab collection gets
a leading "All" row instead of the removed View dropdown, toggling into the
merged grid under its own header. Tabs layout is unchanged.

The "See all" pill on every collection rail (folder screen, both layouts)
and the shelf's "N / M+" position counter are gone; a rail's whole header is
now the focusable/tappable open target instead. The same "See all" link is
dropped from collection rows on Home, since a folder tile is already one tap
away.
Indexer Managers had no scroll-follow for its dynamic list of
configured Jackett/Prowlarr rows, so D-pad-ing through a row's switch
and icon buttons could leave the focused control off-screen. Wire
tvRevealMinimal (already used elsewhere for TV DPAD reveal) as a
listener on each row's focus nodes so any focus arrival scrolls it
into view, mirroring webdav_settings_page.dart's reveal-on-focus idiom.
The Shield's D-pad couldn't reach a chip that wrapped onto a second
row (Details Page has 11 options), and by the time focus reached any
Screen-layouts row the live preview card had already scrolled off the
top of the pane, so highlighting a chip changed a preview nobody
could see.

- Extract the Look strip's single-scrolling-row mechanism into a
  shared TvChipRow widget; SettingsOptionRow now uses it on TV instead
  of Wrap, so every row's chips stay D-pad reachable no matter how
  many options it has. Pointer surfaces keep Wrap.
- Add AppearancePreviewDock: a compact, strip-less copy of the preview
  that overlays the top of the pane (Stack + IgnorePointer, not a
  pushed-down header, so it never shifts a hovered chip out from under
  the pointer) whenever LayoutPreviewChannel reports a Screen-layouts
  row is pointed at. It claims no focus node of its own and collapses
  to nothing once nothing is pointed at. The Look strip needs no
  equivalent — AppearancePreviewCard's own reveal already scrolls the
  resting card into view on strip focus.
- SettingsOptionRow's singleRow now follows the surface a row is built
  for (TV vs desktop) rather than ambient device detection, so the TV
  code path is exercised deterministically in tests too.
The bottom buttons in PikPakFolderPickerDialog handled DPAD UP by
requesting focus directly on the last folder's FocusNode. Since the
folder list is a lazy ListView.builder, that node's row often isn't
mounted (or is off-screen) on trees with more folders than fit on
screen, making requestFocus() a silent no-op -- the same failure mode
stremio_tv_screen.dart's focusRealIndex() documents and fixes.

Add a ScrollController to the folder ListView.builder and a shared
_focusLastFolder() helper: jump the list to its scroll end so the last
row builds, then once the frame lands, use tv_reveal.dart's
tvRevealMinimal() to confirm it's fully visible before focusing it.
All three call sites (New Folder / Cancel / Confirm) now share this
helper instead of duplicating the old jump.

Added isTelevisionOverride and listFilesOverride constructor params
(mirroring InitialSetupFlow.isTelevisionOverride) so widget tests can
exercise the D-pad wiring without a real platform channel or PikPak
API call, plus test/pikpak_folder_picker_dialog_test.dart covering
UP-from-each-button landing on a visible last folder and DOWN from the
last folder still returning to New Folder.
…er, Console

The "More like this" rail (Marquee, Dossier) and grid (Console) are
lazily-built scrollables whose per-card onFocusChange only did setState —
no scroll-into-view. Layout jump helpers (_focusCollection, _focusFirstCell,
_focusRightPane) hand focus to a rec card via a raw FocusNode.requestFocus(),
which does not go through FocusTraversalPolicy.inDirection and therefore
never scrolls on its own, so the D-pad cursor could land on a card sitting
outside the visible viewport.

Every card's onFocusChange now calls Scrollable.ensureVisible (alignment
0.5, explicit policy, post-frame, matching the convention already used in
detail_layout_stage.dart's _TabButton, detail_identity.dart and
catalog_item_tile.dart) whenever it gains focus, regardless of whether that
focus arrived via traversal or a direct jump.

Console's grid cells are not affected by _ReferencePane: that widget only
wraps the separate reference rail (cast/details/guide), a sibling region to
_recsRegion's grid, so the grid's own focus nodes are unaffected by its
scroll handling.
TvMotionController is read above ProfileGate via a ValueListenableBuilder,
which rebuilds its own subtree on change but isn't recognized by the
isolation guard's static scan for the addListener/setState contract every
other above-the-gate controller (AppThemeController, TextBrightnessController)
uses. Since the gate only rekeys its child, that scoped rebuild is otherwise
correct at runtime, but switch to the same explicit addListener + setState
pattern as the other two controllers for consistency and to satisfy the guard.

tv_motion_profile stays profile-scoped: TvMotionController.warm() is
explicitly re-run on every profile switch (see profile_app_lifecycle_participant.dart),
and the pref is stored via ProfilePreferences like the other per-profile style
settings, so a device-level de-scope would be wrong here.
…-rows

Collection folder: plain rows in Rows layout, no See all on collection rails
fix(settings): scroll-follow DPAD focus through indexer manager rows
Appearance: pin the live preview above the pane, fix chip wrap on TV
fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console
Add a TV motion profile: Smooth or Snappy, chosen per box and overridable
…jump

fix(pikpak): scroll last folder into view before DPAD-UP focus jump
Rails now render through a new BoardShelfRow, wrapping the same BoardCell
(and its CardFocusRise scale/shadow/focus-ring/hover-grow chrome) Home's
own board rows paint, instead of DiscoverShelfScope/SeeAllPosterGrid's
Discover-stage card. Matching poster width alone (PR #277) left the card
corner radius, shadow, focus ring, hover-grow and row gutter different
from Home's — this makes a folder's rails an actual Home row rather than
an approximation of one.

BoardShelfRow owns its own focus-node pool and exposes focusFirst() so
the folder screen's existing rail-to-rail DPAD ladder, All row and
per-rail pagination are unchanged. The merged "All" grid and Tabs
single-list view stay on SeeAllPosterGrid — they're poster walls, not
rows, and were never meant to read as Home.
… motion profile

The Smooth/Snappy profile (PR #276) and its shared AppMotion.tvFocus /
tvScroll wiring are correct end to end: the settings row persists the
choice, TvMotionController.select republishes it synchronously, and
main.dart's explicit addListener/setState rebuilds TvMotionScope so
every AppMotion.of(context) consumer retargets on the next frame
(test/theme/tv_motion_profile_test.dart and
test/settings_tv_motion_row_test.dart already pin this).

The reason Smooth read as unchanged on a real Shield is that several
DPAD scroll-follow sites bypass that wiring entirely. Two came before
the motion profile existed:

- board_cell.dart's board card (_StremioCard) and favourite_art_cell's
  ArtPoster hardcoded a flat 140ms/260ms glide with no profile branch
  at all (predates PR #276 by two days).
- episodes_panel.dart's _CompactEpisodeRow hardcoded 220ms, unlike its
  sibling EpisodeTile which already reads AppMotion.tvScroll.

Three more were added six minutes AFTER #276 merged, by PR #281 (the
rec-rail/grid scroll-follow for Marquee, Dossier and Console), which
branched off before #276 landed and so never got the memo: all three
hardcoded `duration: Duration.zero` unconditionally.

Every site now resolves its duration through AppMotion.tvScroll /
AppMotion.scrollTempo, matching the pattern catalog_item_tile.dart and
detail_identity.dart already used. Snappy's figures are unchanged
(140/220ms glides, Duration.zero jumps); Smooth now actually reaches
the profile's 260ms glide on all of them.

Added regression tests that fail on the pre-fix duration and pass
after: tv_motion_profile_scroll_sites_test.dart pins the board card,
detail_layout_rec_scroll_follow_motion_profile_test.dart pins the
Marquee rail (Console/Dossier share the identical fix, exercised by
the existing detail_layout_rec_scroll_follow_test.dart).
Collection folder rails: reuse Home's actual row widget
The live preview scrolled away with the rest of the Appearance pane
(only its own top-of-pane position, not the top of the screen) and the
compact dock shown while hovering a Screen-layouts row squeezed the
stage into a 78px-tall box — both flagged on the Shield.

Restructure each surface (SettingsSpotlightShell, SettingsTvLayout) so
the preview is a fixed header above an Expanded scrolling body, instead
of a Stack overlay riding on top of it. Retire AppearancePreviewDock:
AppearancePreviewHost is now the one preview, pinned directly, and
LayoutPreviewChannel is extended with pointLook/unpointLook/restLook so
a Look hover and a Screen-layouts row hover share the same
pointed/resting model instead of two parallel notifiers. The stage
slot scales with available width up to a new named constant, well past
the old dock's 78px and LayoutPreviewStage's own 320x180 design canvas.

Pane-node accounting for the TV two-pane shell is unchanged: the
preview still claims exactly node zero, whether or not
buildSettingsCategoryChildren inlines the widget itself
(includeAppearancePreview) or a shell builds it separately as the
pinned header.
fix(tv-motion): route the pre-#276 scroll-follow sites through the TV motion profile
…arge

Appearance preview: genuinely pinned header, unified and larger
PR #283 fixed the six row-widget scroll-follow sites but missed the
shared TvFocusScrollWrapper — a completely separate mechanism behind
every YouTube/Reddit/Lemmy/IPTV results row, the episode list, and the
cloud/PikPak/Premiumize file browsers. It hardcoded a flat 200ms glide
with no profile branch at all, so on any of those lists the Smooth/
Snappy toggle changed nothing, matching the "does absolutely nothing"
report even after #283 landed.

Snappy keeps its shipped 200ms glide unchanged; smooth now resolves to
AppMotion.tvScroll (260ms), same convention as every other migrated
site. Added a regression test verified to fail against the pre-fix
200ms literal and pass after.
…ion-profile

fix(tv-motion): route TvFocusScrollWrapper through the motion profile
…ion profile

Canvas is the shipped default TV Home layout (pinned by
tv_home_stage_layouts_pin_test.dart), and it doesn't scroll at all —
DPAD up/down swaps the bottom shelf to a different rail via an
AnimatedSwitcher crossfade, a completely separate mechanism from the
row-widget scroll-follow sites #283/#285 already fixed. That
crossfade hardcoded a flat 200ms with no profile branch, and being a
plain fade with no spatial motion it read as an instant cut regardless
of duration — so on the default layout, Smooth/Snappy visibly changed
nothing for the one interaction most likely to be described as
"scrolling through catalogues".

Snappy keeps the shipped 200ms; smooth now resolves through
AppMotion.tvScroll (260ms). Added a slide to the crossfade so the
switch reads as motion rather than a cut. Added a regression test
verified to fail against the pre-fix flat 200ms and pass after.
… ACTIVE Home layout

PR #282 made the folder rail reuse Home's actual BoardCell/CardFocusRise
widget, but fed it homeRailPosterWidth — the classic board's poster-size
formula. Canvas, the shipped TV default, sizes its own shelf by a
completely different formula (cardH = boardH*0.30 clamped 150-220, then
cardW = cardH * aspect) with different padding grammar, so on the
default layout the folder rail's widget was right but its SIZE still
didn't match what the viewer's actual Home renders.

Added canvasRailCardSize (home_rail_metrics.dart), the same formula
CanvasStage.build uses, and switched the folder screen to pick between
it and the classic formula based on StorageService.tvHomeStyleCached —
so "Rows" reads as this device's actual Home, not just some Home.

Added a regression test that fails to even compile against the pre-fix
code (canvasRailCardSize didn't exist) and passes after, covering both
the classic and Canvas card sizes.
…tion-profile

fix(tv-motion): route Canvas Home stage's rail-switch through the motion profile
…-size

fix(collections): match the collection folder rail's card size to the active Home layout
…witch-motion-profile"

This reverts commit 4957216, reversing
changes made to 7713a26.
A collection folder rail's header was pressable on TV — SELECT opened
the list's full catalog browse — but no Home board row works that way;
a row's name there is a plain label. Made RailHeaderFocus.onPressed
nullable and pass null on TV, so the header stays a DPAD stop (still
walks up to the rail above / down into its cards) but does nothing on
SELECT or tap. Off TV, the tap-to-browse affordance a list title has
always had is unchanged.

Added a regression test that fails to compile against the pre-fix code
(onPressed was required, non-nullable) and passes after, covering both
the TV (inert) and off-TV (still opens) cases, plus a unit test on
RailHeaderFocus itself pinning that a null onPressed makes SELECT and
tap no-ops while the DPAD ladder keeps working.
…electable

fix(collections): TV rail titles are labels, not controls
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant