fix(tv-motion): route the pre-#276 scroll-follow sites through the TV motion profile - #283
Merged
Merged
Conversation
… 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).
This was referenced Sep 8, 2026
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
Reported: the TV Motion Profile setting (Smooth vs. Snappy, PR #276) has no noticeable effect on a real device — "smooth is still very snappy."
Root cause found (with evidence)
The Smooth/Snappy wiring itself is correct end to end and is not the bug:
TvMotionController.select, which setsTvMotionController.notifier.valuesynchronously (lib/services/tv_motion_profile.dart).main.dart's_DebrifyAppStatesubscribes with an explicitaddListener/setState(documented as deliberate, above aProfileGatethat only rekeys its child) and rebuildsTvMotionScopeon every change.AppMotion.of(context)readsTvMotionScope.of(context)inbuild()/didChangeDependencies()at every call site checked (lib/theme/widgets/focus_expression.dart,lib/theme/widgets/hover_grow.dart,lib/widgets/tv_sidebar_nav.dart), so a profile switch retargets tweens on the next frame.test/theme/tv_motion_profile_test.dart("AppMotion.of a scope change rebuilds the dependents") andtest/settings_tv_motion_row_test.dart("a chip press rebuilds a real AppMotion.tvFocus consumer").fast(120ms) +standardcurve, smooth isbase(220ms) +emphasized(overshoot) curve — different durations and different curve shapes (lib/theme/app_motion.dart).The actual bug: several DPAD scroll-follow sites bypass
AppMotionentirely and hardcode a duration, so the profile switch never reaches them.Three sites predate PR Add a TV motion profile: Smooth or Snappy, chosen per box and overridable #276 (which merged 2026-09-07 21:44) by roughly two days and were simply never migrated:
lib/screens/search/board_cell.dart:548(_StremioCard, the Home/Discover board card) — hardcodedwidget.isTelevision ? 140ms : 260ms, no profile branch at all.lib/screens/search/favourite_art_cell.dart:329(ArtPoster) — identical hardcoded 140/260ms pattern.lib/widgets/episodes_panel.dart:2582(_CompactEpisodeRow) — hardcoded 220ms, unlike its siblingEpisodeTile.onFocusChangewhich already readAppMotion.tvScrollcorrectly.Three more were added by PR fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console #281 ("fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console"), which merged at 21:39 — six minutes before Add a TV motion profile: Smooth or Snappy, chosen per box and overridable #276 — so its branch point predates the motion profile:
lib/widgets/detail/detail_layout_console.dart:1031lib/widgets/detail/detail_layout_dossier.dart:684lib/widgets/detail/detail_layout_marquee.dart:450All three hardcoded
duration: Duration.zerounconditionally for the rec grid/rail's scroll-follow-on-focus.git merge-base --is-ancestor dfaea271 d0d9c5d6confirms PR #281's merge commit is an ancestor of PR #276's — i.e. #281's code was written and merged against a tree that didn't have the motion profile yet, and nothing revisited it afterward.Device detection (
TvMotionProfile.defaultFor) and the settings row's persistence/read path were both reviewed and are correct — not the cause.What
Routed all six sites through the shared profile, matching the pattern already used correctly elsewhere (
catalog_item_tile.dart,detail_identity.dart,episode_tile.dart,iptv_channel_row.dart):board_cell.dart/favourite_art_cell.dart:duration: motion.scrollTempo(isTelevision, 260ms, tvSnappy: 140ms)— snappy keeps the shipped 140ms glide unchanged, smooth now reachesAppMotion.tvScroll(260ms).detail_layout_console/dossier/marquee.dart:duration: tv ? motion.tvScroll : Duration.zero— snappy stays an instant jump (unchanged), smooth now glides.episodes_panel.dart:duration: motion.tvScrolldirectly (this row is TV-only), same before/after contract as itsEpisodeTilesibling.Off-TV behavior is untouched everywhere.
How verified
HEADin this worktree, confirming the new test fails, then restoring the fix and confirming it passes — never usedgit stash):test/theme/tv_motion_profile_scroll_sites_test.dart— board card: snappy settles its scroll-follow within 5pumpAndSettleframes (140ms shipped glide, unchanged); smooth needs strictly more (observed 6, driven by the real 260msAppMotion.tvScrollfigure).test/detail_layout_rec_scroll_follow_motion_profile_test.dart— Marquee rec rail: snappy stays within 3 frames (unchanged instant jump); smooth needs at least 5 (observed 6) once it's actually gliding over 260ms instead of jumping. Console/Dossier share the identical fix and are exercised unmodified by the existingtest/detail_layout_rec_scroll_follow_test.dart(still green — the scroll still lands, just now profile-aware).test/theme/tv_motion_profile_test.dart,test/settings_tv_motion_row_test.dart,test/hover_grow_motion_test.dart,test/theme/focus_expression_motion_test.dart,test/theme/shape_type_motion_test.dart,test/detail_layout_rec_scroll_follow_test.dart,test/indexer_managers_settings_page_test.dart,test/pikpak_folder_picker_dialog_test.dart— all pass (105/105).test/detail_layouts_dpad_test.dart,test/detail_showcase_compact_test.dart,test/detail_showcase_test.dart,test/detail_theme_layout_test.dart(1071/1071),test/search_board_cell_origin_test.dart,test/search_public_types_pin_test.dart,test/theme/shape_manifest_test.dart.dart analyzeclean on every changed and new file.Risks
AppMotion.tvScroll/AppMotion.scrollTempo); the snappy-profile branch is written to reproduce the exact prior literal at each site, so default (snappy) behavior is unchanged.pumpAndSettleframe counts rather than sampling a fixed elapsed time, since the post-frame callback's start latency is not deterministic influtter_test; thresholds carry a documented margin against that noise.PlatformUtil.isTelevisionis a global flag read directly in three of the fixed sites (detail_layout_console/dossier/marquee.dart), consistent with the existing pattern in that file family — this can't be overridden per-widget in tests, so the new Marquee test usesPlatformUtil.debugSetAndroidTvCached(true), the existing test-only hook for this.lib/utils/tv_reveal.dart(tvRevealMinimal, used by Settings pages like Indexer Managers/IPTV/Quick Play) orsource_row.dart/torrent_result_row.dart/provider_status_cards.dart: none of these branch onisTelevisionfor their duration (same figure on every platform), so they aren't a differential "TV motion profile" regression the way the six fixed sites were — flagging them here rather than silently expanding scope.