Skip to content

Add a TV motion profile: Smooth or Snappy, chosen per box and overridable - #276

Merged
ghbarker merged 2 commits into
feature/post-refactorfrom
feature/tv-motion-profile
Sep 8, 2026
Merged

ghbarker merged 2 commits into
feature/post-refactorfrom
feature/tv-motion-profile

Conversation

@ghbarker

@ghbarker ghbarker commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Why

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

What

  • TvMotionProfile { smooth, snappy } with a per-device default: smooth
    on tvOS and on Android TV boxes reporting the NVIDIA Shield (by
    manufacturer, model, board, or device name), snappy on every other
    Android TV. Off television the profile is unread.
  • lib/services/tv_motion_profile.dart: 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.
  • lib/theme/tv_motion_scope.dart: TvMotionScope, 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.
  • AppMotion gains profile, tvFocusCurve, tvScroll/tvScrollCurve,
    scrollTempo, tvEntrance/entrance, and 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 — 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 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 already 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 extra row.

How verified

  • theme/tv_motion_profile_test.dart (new, 24 tests): device default,
    the pref, the controller, every AppMotion figure under both profiles,
    TvMotionScope propagation.
  • settings_tv_motion_row_test.dart (new, 5 tests): catalog wiring —
    exactly one pref key on choice, TV Left/Right/Down, pane node release,
    a chip press rebuilding a live AppMotion.tvFocus consumer.
  • Ran and confirmed unchanged: shape_type_motion_test (TV policy pins —
    entrance none under snappy, ring floor / blur cap unchanged),
    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 (confirms the
    cast-tap-to-actor feature from Cast tiles open the actor's known-for titles #273 is untouched).
  • dart analyze on every changed file: clean (same pre-existing infos as
    origin, no new diagnostics). tool/check_layering.dart: 42/42, no
    growth. tool/analyze_baseline.py's reported "NEW diagnostics" are
    pre-existing SDK-drift noise, reproduced identically on a clean
    origin/feature/post-refactor checkout with none of this branch's
    files implicated.
  • Local golden mismatches in settings_tv_spotlight_layout_test and
    debrify_tv_spotlight_dialog_test are pre-existing (Windows-rendered
    vs CI's Linux-rendered goldens) — reproduced identically on a clean
    checkout; not touched here.

Risks

  • Device detection for "is this a Shield" relies on manufacturer/
    model/board/hardware via device_info_plus, with the existing TV
    listener's device-name channel as a fallback; an OEM reporting none of
    the known Shield spellings falls back to snappy (the safe default) even
    if it is Shield-class hardware.
  • The emphasized curve is applied only to properties confirmed to be
    pure transforms at each of the routed sites; if a future edit adds a
    decoration/size tween to one of those AnimatedScales without noticing
    the shared duration, it would need its own curve.

…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).
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.
@ghbarker

ghbarker commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

CI fix: stale_runtime_guard_test.dart flagged TvMotionController as read above ProfileGate without being subscribed to (the gate only rekeys its child, so anything above it must subscribe or the incoming profile inherits the outgoing profile's value).

Determined tv_motion_profile is correctly profile-scoped, not device-level: TvMotionController.warm() is explicitly re-run on every profile switch by ProfileAppLifecycleParticipant, and the pref is persisted via ProfilePreferences, same as the other per-profile style settings (theme, text brightness). So the fix is listener wiring, not de-scoping.

Switched TvMotionController from a scoped ValueListenableBuilder (which does rebuild correctly at runtime but isn't recognized by the guard's static scan) to the same explicit addListener/removeListener + setState pattern AppThemeController and TextBrightnessController already use in _DebrifyAppState. Updated the doc comments on TvMotionScope and in main.dart to match.

@ghbarker
ghbarker merged commit d0d9c5d into feature/post-refactor Sep 8, 2026
3 checks passed
ghbarker added a commit that referenced this pull request Sep 8, 2026
fix(tv-motion): route the pre-#276 scroll-follow sites through the TV motion profile
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