Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions lib/screens/search/board_cell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/services.dart';

import '../../models/stremio_addon.dart';
import '../../services/main_page_bridge.dart';
import '../../theme/app_motion.dart';
import '../../theme/app_theme_scope.dart';
import '../../utils/dialog_tap_guard.dart';
import '../../utils/tv_keys.dart';
Expand Down Expand Up @@ -341,6 +342,7 @@ class _StremioCardState extends State<_StremioCard>
@override
Widget build(BuildContext context) {
final app = AppThemeScope.of(context);
final motion = AppMotion.of(context);
final item = widget.item;
final wide = widget.aspectRatio > 1;
final poster = widget.artUrl ?? item.poster;
Expand Down Expand Up @@ -544,11 +546,16 @@ class _StremioCardState extends State<_StremioCard>
// repeat retargets the in-flight scroll from the CURRENT offset,
// and a short glide converges on the focused card fast enough
// that motion never reads as trailing the keypress (200ms felt
// laggy on-device).
duration: widget.isTelevision
? const Duration(milliseconds: 140)
: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
// laggy on-device). That figure is the SNAPPY profile's; under
// smooth this follows `AppMotion.tvScroll` instead, like every
// other TV scroll-follow — this predates the motion profile
// (added before PR #276) and was never routed through it.
duration: motion.scrollTempo(
widget.isTelevision,
const Duration(milliseconds: 260),
tvSnappy: const Duration(milliseconds: 140),
),
curve: motion.tvScrollCurve,
);
});
}
Expand Down
18 changes: 13 additions & 5 deletions lib/screens/search/favourite_art_cell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../../services/main_page_bridge.dart';
import '../../theme/app_motion.dart';
import '../../theme/app_theme_scope.dart';
import '../../utils/tv_keys.dart';
import '../../widgets/home/card_focus_rise.dart';
Expand Down Expand Up @@ -197,6 +198,7 @@ class _ArtPosterState extends State<ArtPoster> {
@override
Widget build(BuildContext context) {
final app = AppThemeScope.of(context);
final motion = AppMotion.of(context);
final url = widget.imageUrl;
final hasImage = url != null && url.isNotEmpty;

Expand Down Expand Up @@ -329,11 +331,17 @@ class _ArtPosterState extends State<ArtPoster> {
// TV glides too (was a hard jump) — see _StremioCard: repeated
// DPAD moves retarget the in-flight scroll, so held browsing
// stays one continuous motion. Short on purpose; 200ms trailed
// the keypress on-device.
duration: widget.isTelevision
? const Duration(milliseconds: 140)
: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
// the keypress on-device. That figure is the SNAPPY profile's;
// under smooth this follows `AppMotion.tvScroll` instead, like
// every other TV scroll-follow — this predates the motion
// profile (added before PR #276) and was never routed through
// it.
duration: motion.scrollTempo(
widget.isTelevision,
const Duration(milliseconds: 260),
tvSnappy: const Duration(milliseconds: 140),
),
curve: motion.tvScrollCurve,
);
});
}
Expand Down
12 changes: 11 additions & 1 deletion lib/widgets/detail/detail_layout_console.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import '../../services/debrify_image_cache.dart';
import '../../services/imdb_enrichment_service.dart';
import '../../services/storage_service.dart';
import '../../theme/app_motion.dart';
import '../../utils/platform_util.dart';
import '../episodes_panel.dart';
import '../parents_guide_section.dart';
Expand Down Expand Up @@ -1021,14 +1022,23 @@ class _ConsolePosterState extends State<_ConsolePoster> {
// have moved focus programmatically instead of via the
// framework's own key-driven traversal). Follow explicitly so
// the cursor is never invisible.
//
// This predates the TV motion profile (PR #281 landed before
// #276) and was left on a bare snap. Route it through
// `AppMotion.tvScroll` like every other TV scroll-follow: zero
// under snappy (unchanged), the profile's glide under smooth.
// Off TV the jump is untouched.
if (f) {
final tv = PlatformUtil.isTelevision;
final motion = AppMotion.of(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !context.mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: Duration.zero,
duration: tv ? motion.tvScroll : Duration.zero,
curve: motion.tvScrollCurve,
);
});
}
Expand Down
13 changes: 12 additions & 1 deletion lib/widgets/detail/detail_layout_dossier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'package:flutter/services.dart';

import '../../services/debrify_image_cache.dart';
import '../../services/imdb_enrichment_service.dart';
import '../../theme/app_motion.dart';
import '../../utils/platform_util.dart';
import '../episodes_panel.dart';
import '../horizontal_mouse_wheel.dart';
import '../parents_guide_section.dart';
Expand Down Expand Up @@ -674,14 +676,23 @@ class _RecPosterState extends State<_RecPoster> {
// `_rightKey` — may have moved focus programmatically instead
// of via the framework's own key-driven traversal). Follow
// explicitly so the cursor is never invisible.
//
// This predates the TV motion profile (PR #281 landed before
// #276) and was left on a bare snap. Route it through
// `AppMotion.tvScroll` like every other TV scroll-follow: zero
// under snappy (unchanged), the profile's glide under smooth.
// Off TV the jump is untouched.
if (f) {
final tv = PlatformUtil.isTelevision;
final motion = AppMotion.of(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !context.mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: Duration.zero,
duration: tv ? motion.tvScroll : Duration.zero,
curve: motion.tvScrollCurve,
);
});
}
Expand Down
14 changes: 13 additions & 1 deletion lib/widgets/detail/detail_layout_marquee.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:flutter/material.dart';

import '../../models/stremio_addon.dart';
import '../../services/debrify_image_cache.dart';
import '../../theme/app_motion.dart';
import '../../utils/platform_util.dart';
import '../episodes_panel.dart';
import '../horizontal_mouse_wheel.dart';
import 'detail_episode_cells.dart';
Expand Down Expand Up @@ -440,14 +442,24 @@ class _RecCardState extends State<_RecCard> {
// ancestor onKeyEvent may have moved focus programmatically
// instead of via the framework's own key-driven traversal).
// Follow explicitly so the cursor is never invisible.
//
// This predates the TV motion profile (PR #281 landed
// before #276) and was left on a bare snap. Route it
// through `AppMotion.tvScroll` like every other TV
// scroll-follow: zero under snappy (unchanged), the
// profile's glide under smooth. Off TV the jump is
// untouched.
if (f) {
final tv = PlatformUtil.isTelevision;
final motion = AppMotion.of(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !context.mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: Duration.zero,
duration: tv ? motion.tvScroll : Duration.zero,
curve: motion.tvScrollCurve,
);
});
}
Expand Down
10 changes: 8 additions & 2 deletions lib/widgets/episodes_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../utils/platform_util.dart';
import '../utils/episode_progress_merge.dart';
import '../utils/tv_keys.dart';
import 'tv_focus_scroll_wrapper.dart';
import '../theme/app_motion.dart';
import '../theme/app_theme_scope.dart';
import 'detail/detail_style.dart';
import 'detail/theme/detail_theme.dart';
Expand Down Expand Up @@ -2575,12 +2576,17 @@ class _CompactEpisodeRowState extends State<_CompactEpisodeRow> {
onFocusChange: (f) {
if (mounted) setState(() => _focused = f);
if (f && widget.isTelevision && context.mounted) {
// TV: `AppMotion.tvScroll` — the snap under snappy, the profile's
// glide under smooth. This row's own hardcoded 220ms predated the
// motion profile and was never routed through it, unlike the
// sibling `EpisodeTile.onFocusChange`.
final motion = AppMotion.of(context);
Scrollable.ensureVisible(
context,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
duration: motion.tvScroll,
curve: motion.tvScrollCurve,
);
}
},
Expand Down
189 changes: 189 additions & 0 deletions test/detail_layout_rec_scroll_follow_motion_profile_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:debrify/models/stremio_addon.dart';
import 'package:debrify/services/tv_motion_profile.dart';
import 'package:debrify/utils/platform_util.dart';
import 'package:debrify/widgets/detail/detail_layout_marquee.dart';
import 'package:debrify/widgets/detail/detail_model.dart';
import 'package:debrify/widgets/detail/theme/detail_theme.dart';
import 'package:debrify/widgets/detail/theme/detail_themes.dart';

/// Pins the fix for `DetailMarquee`'s rec-rail scroll-follow (mirrored
/// identically in `DetailConsole`/`DetailDossier` — see
/// `detail_layout_rec_scroll_follow_test.dart` for the shared fixture that
/// proves the scroll itself still lands, unaffected by this change).
///
/// The rail's `onFocusChange` hardcoded `duration: Duration.zero` — a bare
/// jump — regardless of the TV motion profile. PR #281 (this scroll-follow)
/// landed at 2026-09-07 21:39 and PR #276 (the motion profile) landed six
/// minutes later at 21:44, so #281 simply predates the profile and was never
/// migrated to read it, unlike every other TV scroll-follow site added
/// before #276.
///
/// Before the fix, both profiles jump instantly (`Duration.zero`), settling
/// in the same one or two `pumpAndSettle` frames. After the fix, snappy
/// keeps the instant jump but smooth now animates over `AppMotion.tvScroll`
/// (260ms), needing measurably more frames to land.
const _tv = Size(960, 540);

List<StremioMeta> _recs(int count) => [
for (var i = 0; i < count; i++)
StremioMeta(
id: 'tt300$i',
imdbId: 'tt300$i',
type: 'movie',
name: 'Rec $i',
poster: null,
background: null,
description: null,
year: '2020',
genres: const [],
),
];

DetailModel _movieModel({required List<StremioMeta> recs}) {
final item = StremioMeta(
id: 'tt0000002',
imdbId: 'tt0000002',
type: 'movie',
name: 'A Movie',
poster: null,
background: null,
description: null,
year: '2020',
genres: const [],
);
return DetailModel(
item: item,
isMovie: true,
isTelevision: true,
accent: const Color(0xFFABA124),
imdbExtra: null,
parentsGuide: null,
recommendations: recs,
primaryLabel: 'Play',
sourceCount: 2,
hasTrailer: false,
trailerBusy: false,
trailerPlaying: false,
hasTrakt: false,
traktTracked: false,
traktLabel: 'Watchlist',
traktRating: null,
hasSimkl: false,
simklTracked: false,
simklLabel: 'Watching',
simklRating: null,
showPrimary: true,
onPrimary: () {},
onBrowse: null,
onTrailer: () {},
onSelectSource: () {},
onAppMenu: () {},
onTraktMenu: () {},
onSimklMenu: () {},
onRecommendationTap: (_) {},
onAmbientStill: (_) {},
focus: DetailFocusCoordinator(
backNode: FocusNode(debugLabel: 'test-back'),
primaryEntry: FocusNode(debugLabel: 'test-primary'),
),
);
}

Future<void> _pump(WidgetTester tester, Widget child) async {
tester.view.physicalSize = _tv;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: _tv, devicePixelRatio: 1.0),
child: MaterialApp(
home: Scaffold(
backgroundColor: DetailThemes.signal.ground,
body: DetailThemeScope(theme: DetailThemes.signal, child: child),
),
),
),
);
await tester.pump(const Duration(milliseconds: 400));
}

Future<int> _focusCardAndSettle(WidgetTester tester, Finder list, int index) async {
final cards = find.descendant(of: list, matching: find.byType(InkWell));
final leaf = find
.descendant(of: cards.at(index), matching: find.byType(ColoredBox))
.first;
final node = Focus.of(tester.element(leaf), createDependency: false);
node.requestFocus();
return tester.pumpAndSettle();
}

void main() {
setUp(() {
TvMotionController.debugReset();
// The scroll-follow's own TV gate reads the global platform flag, not
// the fixture's DetailModel.isTelevision — force it on so the fixed
// branch (`tv ? motion.tvScroll : Duration.zero`) actually engages.
PlatformUtil.debugSetAndroidTvCached(true);
});

tearDown(() {
TvMotionController.debugReset();
PlatformUtil.debugSetAndroidTvCached(null);
});

// The snappy-profile ceiling: how many `pumpAndSettle` frames the
// unchanged instant jump needs.
const snappyCeilingPumps = 3;


testWidgets(
'Marquee snappy: the rec rail still jumps instantly, within a couple of '
'frames',
(tester) async {
final model = _movieModel(recs: _recs(20));
await _pump(tester, DetailMarquee(model: model, episodesHost: null));

final list = find.byType(ListView);
final pumps = await _focusCardAndSettle(tester, list, 8);

expect(
pumps,
lessThanOrEqualTo(snappyCeilingPumps),
reason: 'snappy keeps the shipped instant jump',
);
},
);

testWidgets(
'Marquee smooth: the rec rail now glides on AppMotion.tvScroll instead '
'of the old unconditional instant jump',
(tester) async {
TvMotionController.select(TvMotionProfile.smooth);
final model = _movieModel(recs: _recs(20));
await _pump(tester, DetailMarquee(model: model, episodesHost: null));

final list = find.byType(ListView);
final pumps = await _focusCardAndSettle(tester, list, 8);

// A generous margin above snappyCeilingPumps rather than a bare
// greaterThan: the unconditional pre-fix Duration.zero already varies
// by a pump or two run to run (observed 3-4), so a one-pump margin is
// not a reliable signal. The real 260ms glide this fix adds needs
// several more settle iterations (observed 6) — comfortably clear of
// that noise floor.
expect(
pumps,
greaterThanOrEqualTo(snappyCeilingPumps + 2),
reason:
'the smooth profile should glide over AppMotion.tvScroll '
'(260ms), needing measurably more frames than the snappy '
'instant jump. A pump count within a pump or two of the snappy '
'ceiling means the rail is still on the old unconditional '
'Duration.zero.',
);
},
);
}
Loading
Loading