Skip to content

fix(tv-motion): route the pre-#276 scroll-follow sites through the TV motion profile - #283

Merged
ghbarker merged 1 commit into
feature/post-refactorfrom
fix/tv-motion-profile-no-effect
Sep 8, 2026
Merged

ghbarker merged 1 commit into
feature/post-refactorfrom
fix/tv-motion-profile-no-effect

Conversation

@ghbarker

@ghbarker ghbarker commented Sep 8, 2026

Copy link
Copy Markdown
Owner

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:

  • The settings row persists the choice via TvMotionController.select, which sets TvMotionController.notifier.value synchronously (lib/services/tv_motion_profile.dart).
  • main.dart's _DebrifyAppState subscribes with an explicit addListener/setState (documented as deliberate, above a ProfileGate that only rekeys its child) and rebuilds TvMotionScope on every change.
  • AppMotion.of(context) reads TvMotionScope.of(context) in build()/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.
  • This is already covered by passing tests: test/theme/tv_motion_profile_test.dart ("AppMotion.of a scope change rebuilds the dependents") and test/settings_tv_motion_row_test.dart ("a chip press rebuilds a real AppMotion.tvFocus consumer").
  • The numeric gap is real and not accidentally aliased: snappy is fast (120ms) + standard curve, smooth is base (220ms) + emphasized (overshoot) curve — different durations and different curve shapes (lib/theme/app_motion.dart).

The actual bug: several DPAD scroll-follow sites bypass AppMotion entirely and hardcode a duration, so the profile switch never reaches them.

  1. 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) — hardcoded widget.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 sibling EpisodeTile.onFocusChange which already read AppMotion.tvScroll correctly.
  2. 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:1031
    • lib/widgets/detail/detail_layout_dossier.dart:684
    • lib/widgets/detail/detail_layout_marquee.dart:450

    All three hardcoded duration: Duration.zero unconditionally for the rec grid/rail's scroll-follow-on-focus.

git merge-base --is-ancestor dfaea271 d0d9c5d6 confirms 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 reaches AppMotion.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.tvScroll directly (this row is TV-only), same before/after contract as its EpisodeTile sibling.

Off-TV behavior is untouched everywhere.

How verified

  • New regression tests that fail on the pre-fix code and pass after (verified both ways by reverting each file to HEAD in this worktree, confirming the new test fails, then restoring the fix and confirming it passes — never used git stash):
    • test/theme/tv_motion_profile_scroll_sites_test.dart — board card: snappy settles its scroll-follow within 5 pumpAndSettle frames (140ms shipped glide, unchanged); smooth needs strictly more (observed 6, driven by the real 260ms AppMotion.tvScroll figure).
    • 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 existing test/detail_layout_rec_scroll_follow_test.dart (still green — the scroll still lands, just now profile-aware).
  • Ran the full requested suite plus the new tests together: 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).
  • Also ran the broader suites touching the changed files for extra confidence: 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 analyze clean on every changed and new file.

Risks

  • Low. Every change is additive routing through an already-tested API (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.
  • The two new tests infer animation duration from pumpAndSettle frame counts rather than sampling a fixed elapsed time, since the post-frame callback's start latency is not deterministic in flutter_test; thresholds carry a documented margin against that noise.
  • PlatformUtil.isTelevision is 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 uses PlatformUtil.debugSetAndroidTvCached(true), the existing test-only hook for this.
  • Did not touch lib/utils/tv_reveal.dart (tvRevealMinimal, used by Settings pages like Indexer Managers/IPTV/Quick Play) or source_row.dart/torrent_result_row.dart/provider_status_cards.dart: none of these branch on isTelevision for 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.

… 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).
@ghbarker
ghbarker merged commit 4d36cc1 into feature/post-refactor Sep 8, 2026
3 checks passed
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