From 1adeeff47cc44e2aebe03aecfa5421039727981b Mon Sep 17 00:00:00 2001 From: Ghbarker Date: Mon, 7 Sep 2026 21:21:22 -0400 Subject: [PATCH] fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/widgets/detail/detail_layout_console.dart | 21 +- lib/widgets/detail/detail_layout_dossier.dart | 21 +- lib/widgets/detail/detail_layout_marquee.dart | 21 +- .../detail_layout_rec_scroll_follow_test.dart | 246 ++++++++++++++++++ 4 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 test/detail_layout_rec_scroll_follow_test.dart diff --git a/lib/widgets/detail/detail_layout_console.dart b/lib/widgets/detail/detail_layout_console.dart index deb4ebbd5..cba235b42 100644 --- a/lib/widgets/detail/detail_layout_console.dart +++ b/lib/widgets/detail/detail_layout_console.dart @@ -1013,7 +1013,26 @@ class _ConsolePosterState extends State<_ConsolePoster> { child: InkWell( focusNode: widget.focusNode, onTap: widget.onTap, - onFocusChange: (f) => setState(() => _focused = f), + onFocusChange: (f) { + setState(() => _focused = f); + // Lazily-built grid: default traversal can land here without + // ever scrolling it into view (the cell may have just entered + // the cache extent, or DetailEdgeTrap's 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. + if (f) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !context.mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: Duration.zero, + ); + }); + } + }, child: (p != null && p.isNotEmpty) ? CachedNetworkImage( imageUrl: p, diff --git a/lib/widgets/detail/detail_layout_dossier.dart b/lib/widgets/detail/detail_layout_dossier.dart index acb3156c0..70b37ffc3 100644 --- a/lib/widgets/detail/detail_layout_dossier.dart +++ b/lib/widgets/detail/detail_layout_dossier.dart @@ -666,7 +666,26 @@ class _RecPosterState extends State<_RecPoster> { clipBehavior: Clip.antiAlias, child: InkWell( onTap: widget.onTap, - onFocusChange: (f) => setState(() => _focused = f), + onFocusChange: (f) { + setState(() => _focused = f); + // Lazily-built horizontal rail: default traversal can land here + // without ever scrolling it into view (the card may have just + // entered the cache extent, or the pane's own onKeyEvent — see + // `_rightKey` — may have moved focus programmatically instead + // of via the framework's own key-driven traversal). Follow + // explicitly so the cursor is never invisible. + if (f) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !context.mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: Duration.zero, + ); + }); + } + }, child: (poster != null && poster.isNotEmpty) ? CachedNetworkImage( imageUrl: poster, diff --git a/lib/widgets/detail/detail_layout_marquee.dart b/lib/widgets/detail/detail_layout_marquee.dart index b986ef0ff..8be445812 100644 --- a/lib/widgets/detail/detail_layout_marquee.dart +++ b/lib/widgets/detail/detail_layout_marquee.dart @@ -432,7 +432,26 @@ class _RecCardState extends State<_RecCard> { child: InkWell( focusNode: widget.focusNode, onTap: widget.onTap, - onFocusChange: (f) => setState(() => _focused = f), + onFocusChange: (f) { + setState(() => _focused = f); + // Lazily-built horizontal rail: default traversal can land + // here without ever scrolling it into view (the card may + // have just entered the cache extent, or DetailEdgeTrap's + // 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. + if (f) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !context.mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: Duration.zero, + ); + }); + } + }, child: AspectRatio( aspectRatio: 2 / 3, child: (poster != null && poster.isNotEmpty) diff --git a/test/detail_layout_rec_scroll_follow_test.dart b/test/detail_layout_rec_scroll_follow_test.dart new file mode 100644 index 000000000..b25a625de --- /dev/null +++ b/test/detail_layout_rec_scroll_follow_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:debrify/models/stremio_addon.dart'; +import 'package:debrify/services/imdb_enrichment_service.dart'; +import 'package:debrify/widgets/detail/detail_layout_console.dart'; +import 'package:debrify/widgets/detail/detail_layout_dossier.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'; + +/// Proves the "More like this" rail/grid in Marquee, Dossier and Console +/// scrolls to follow a card that gains focus, so the DPAD cursor is never +/// left on a card sitting outside the visible viewport. +/// +/// The three layouts hand focus to a rec card two ways: arrow-key traversal +/// (which Flutter's own `FocusTraversalPolicy.inDirection` already reveals), +/// and a direct `FocusNode.requestFocus()` jump — `_focusCollection` / +/// `_focusFirstCell` (Marquee, Console) and `_focusRightPane` (Dossier) all +/// hand off this way when DOWN/RIGHT crosses in from the action row. A plain +/// `requestFocus()` does NOT go through the traversal policy, so it never +/// scrolls on its own — that gap is exactly the bug being fixed. +/// +/// Each test reproduces it directly: build enough recommendations that a +/// lazily-built list constructs a handful past the visible edge (Flutter's +/// default ~250px cache extent) without scrolling to them, grab one of those +/// built-but-off-screen cards' FocusNode from the live tree, call +/// `requestFocus()` on it exactly as the layout's own jump helpers do, and +/// assert the card's rect ends up inside the scrollable's viewport. + +const _tv = Size(960, 540); + +List _recs(int count) => [ + for (var i = 0; i < count; i++) + StremioMeta( + id: 'tt100$i', + imdbId: 'tt100$i', + type: 'movie', + name: 'Rec $i', + poster: null, + background: null, + description: null, + year: '2020', + genres: const [], + ), +]; + +DetailModel _movieModel({ + required List recs, + ImdbEnrichment? imdbExtra, +}) { + final item = StremioMeta( + id: 'tt0000001', + imdbId: 'tt0000001', + 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: imdbExtra, + 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 _pump( + WidgetTester tester, + Widget child, { + required List recs, + ImdbEnrichment? imdbExtra, +}) 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)); +} + +/// Grabs the [index]th rec card's own FocusNode from inside [scrollable] — +/// whether or not the card wires one in explicitly (Marquee/Console pass one +/// in; Dossier's `_RecPoster` lets `InkWell` create its own) — and hands +/// focus to it directly, exactly as `_focusCollection` / `_focusFirstCell` / +/// `_focusRightPane` do in production. +Future _requestFocusOnCard( + WidgetTester tester, + Finder scrollable, + int index, +) async { + final cards = find.descendant( + of: scrollable, + matching: find.byType(InkWell), + ); + final built = cards.evaluate().length; + expect( + index, + lessThan(built), + reason: 'fixture did not lazily-build a card at index $index ' + '(only $built built) — widen the recommendation list', + ); + // Any descendant of the InkWell finds the same governing FocusNode, + // regardless of whether the card supplied its own. + final leaf = find + .descendant(of: cards.at(index), matching: find.byType(ColoredBox)) + .first; + final node = Focus.of(tester.element(leaf), createDependency: false); + node.requestFocus(); + await tester.pump(); + // The fix's ensureVisible runs in a post-frame callback. + await tester.pump(); + await tester.pump(); + + final ctx = FocusManager.instance.primaryFocus?.context; + if (ctx == null || !ctx.mounted) return null; + final box = ctx.findRenderObject(); + if (box is! RenderBox || !box.attached) return null; + return box.localToGlobal(Offset.zero) & box.size; +} + +void main() { + testWidgets( + 'Marquee: jumping focus onto an off-screen rec card scrolls it into view', + (tester) async { + final model = _movieModel(recs: _recs(20)); + await _pump( + tester, + DetailMarquee(model: model, episodesHost: null), + recs: _recs(20), + ); + + final list = find.byType(ListView); + final rect = await _requestFocusOnCard(tester, list, 8); + expect(rect, isNotNull, reason: 'requestFocus did not take'); + + final viewport = tester.getRect(list.first); + expect( + viewport.left <= rect!.left + 0.5 && rect.right <= viewport.right + 0.5, + isTrue, + reason: 'focused card $rect is outside viewport $viewport', + ); + }, + ); + + testWidgets( + 'Dossier: jumping focus onto an off-screen rec card scrolls it into view', + (tester) async { + final model = _movieModel(recs: _recs(20)); + await _pump( + tester, + DetailDossier(model: model, episodesHost: null), + recs: _recs(20), + ); + + // Cast/details/guide are all empty in this fixture, so the reference + // pane's only content is the horizontal "More like this" list. + final list = find.byWidgetPredicate( + (w) => w is ListView && w.scrollDirection == Axis.horizontal, + ); + final rect = await _requestFocusOnCard(tester, list, 5); + expect(rect, isNotNull, reason: 'requestFocus did not take'); + + final viewport = tester.getRect(list.first); + expect( + viewport.left <= rect!.left + 0.5 && rect.right <= viewport.right + 0.5, + isTrue, + reason: 'focused card $rect is outside viewport $viewport', + ); + }, + ); + + testWidgets( + 'Console: jumping focus onto an off-screen grid cell scrolls it into view', + (tester) async { + final model = _movieModel( + recs: _recs(30), + // Non-empty cast puts the reference rail beside the grid (wide/TV + // side-by-side layout), so the grid gets its own bounded, + // independently-scrolling viewport instead of the shrink-wrapped, + // whole-page-scrolls stacked one. + imdbExtra: const ImdbEnrichment(cast: [CastMember(name: 'Someone')]), + ); + await _pump( + tester, + DetailConsole(model: model, episodesHost: null), + recs: _recs(30), + imdbExtra: const ImdbEnrichment(cast: [CastMember(name: 'Someone')]), + ); + + final grid = find.byType(GridView); + final rect = await _requestFocusOnCard(tester, grid, 4); + expect(rect, isNotNull, reason: 'requestFocus did not take'); + + final viewport = tester.getRect(grid.first); + expect( + viewport.top <= rect!.top + 0.5 && rect.bottom <= viewport.bottom + 0.5, + isTrue, + reason: 'focused cell $rect is outside viewport $viewport', + ); + }, + ); +}