Conversation
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.
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
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.
Revert #286 only (Canvas rail-switch motion)
…electable fix(collections): TV rail titles are labels, not controls
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.
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.