From cbea1c8d8f6e296151754848433ed2ef41e94754 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 21:32:26 +0200 Subject: [PATCH] fix(tv): correct long hub row focus scrolling --- lib/utils/scroll_utils.dart | 56 +++++++++++++++++++++- lib/widgets/hub_section.dart | 16 ++++++- lib/widgets/tv_browse_rail.dart | 10 ++++ test/utils/scroll_utils_test.dart | 38 +++++++++++++++ test/widgets/tv_browse_rail_test.dart | 67 +++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 7cee80a4..7515632e 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; /// Scroll the nearest scrollable ancestor so [context] is centered. @@ -70,8 +73,59 @@ void scrollListToIndex( final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent); if (animate) { - controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + unawaited(controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut)); } else { controller.jumpTo(desiredOffset); } } + +/// Scroll a horizontal list so the keyed child is centered using its real layout +/// bounds. This corrects small per-item extent drift in long carousels. +void scrollKeyedChildToHorizontalCenter( + ScrollController controller, + GlobalKey key, { + bool animate = true, + int maxAttempts = 2, +}) { + void schedule(int attempt) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = key.currentContext; + if (context == null) { + if (attempt < maxAttempts) schedule(attempt + 1); + return; + } + + final didResolve = _scrollContextToHorizontalCenterNow(controller, context, animate: animate); + if (!didResolve && attempt < maxAttempts) schedule(attempt + 1); + }); + } + + schedule(0); +} + +bool _scrollContextToHorizontalCenterNow(ScrollController controller, BuildContext context, {required bool animate}) { + if (!context.mounted || controller.positions.length != 1) return true; + + final position = controller.position; + if (position.axis != Axis.horizontal) return true; + + final renderObject = context.findRenderObject(); + if (renderObject == null || !renderObject.attached) return false; + + final viewport = RenderAbstractViewport.maybeOf(renderObject); + if (viewport == null) return false; + + final target = viewport + .getOffsetToReveal(renderObject, 0.5) + .offset + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if ((target - position.pixels).abs() < 0.5) return true; + + if (animate) { + unawaited(controller.animateTo(target, duration: const Duration(milliseconds: 150), curve: Curves.easeOut)); + } else { + controller.jumpTo(target); + } + return true; +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 16dec70c..1a2bb6e0 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -125,12 +125,14 @@ class HubSectionState extends State with MountedSetStateMixin { void didUpdateWidget(HubSection oldWidget) { super.didUpdateWidget(oldWidget); if (widget.hub.id != oldWidget.hub.id) { + _itemKeys.clear(); _mediaCardKeys.clear(); - } else if (widget.hub.items.length != oldWidget.hub.items.length) { + } else if (widget.hub.items.length != oldWidget.hub.items.length || widget.hub.more != oldWidget.hub.more) { + _itemKeys.removeWhere((index, _) => index >= _totalItemCount); _mediaCardKeys.removeWhere((index, _) => index >= widget.hub.items.length); } - if (widget.hub.items.length != oldWidget.hub.items.length) { + if (widget.hub.items.length != oldWidget.hub.items.length || widget.hub.more != oldWidget.hub.more) { final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1; if (_focusedIndex > maxIndex) { _focusedIndex = maxIndex; @@ -211,6 +213,9 @@ class HubSectionState extends State with MountedSetStateMixin { leadingPadding: _leadingPadding, animate: animate, ); + if (index >= 0 && index < _totalItemCount) { + scrollKeyedChildToHorizontalCenter(_scrollController, _itemKeyFor(index), animate: animate); + } } /// Handle ALL key events at the hub level @@ -316,8 +321,13 @@ class HubSectionState extends State with MountedSetStateMixin { } /// GlobalKeys for MediaCards to access their state (for context menu) + final Map _itemKeys = {}; final Map> _mediaCardKeys = {}; + GlobalKey _itemKeyFor(int index) { + return _itemKeys.putIfAbsent(index, () => GlobalKey()); + } + GlobalKey _getMediaCardKey(int index) { return _mediaCardKeys.putIfAbsent(index, () => GlobalKey()); } @@ -492,6 +502,7 @@ class HubSectionState extends State with MountedSetStateMixin { if (index == widget.hub.items.length) { return Padding( + key: _itemKeyFor(index), padding: widget.inset ? const EdgeInsets.only(right: 4) : const EdgeInsets.symmetric(horizontal: 2), @@ -533,6 +544,7 @@ class HubSectionState extends State with MountedSetStateMixin { final item = widget.hub.items[index]; return Padding( + key: _itemKeyFor(index), padding: widget.inset ? const EdgeInsets.only(right: 4) : const EdgeInsets.symmetric(horizontal: 2), diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 5d115cb4..848ffe3f 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -295,6 +295,7 @@ class TvBrowseRailState extends State { final Map _scrollControllers = {}; final ScrollController _verticalController = ScrollController(); final Map _hubSectionKeys = {}; + final Map _itemKeys = {}; final Map> _mediaCardKeys = {}; int _hubIndex = 0; @@ -656,6 +657,9 @@ class TvBrowseRailState extends State { leadingPadding: _railLeadingPadding, animate: animate, ); + if (_itemIndex >= 0 && _itemIndex < _totalItemCount(hub)) { + scrollKeyedChildToHorizontalCenter(controller, _itemKeyFor(hub, _itemIndex), animate: animate); + } } void _scrollToItemAfterLayout({bool animate = true}) { @@ -693,6 +697,10 @@ class TvBrowseRailState extends State { return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey()); } + GlobalKey _itemKeyFor(MediaHub hub, int itemIndex) { + return _itemKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey()); + } + void _showContextMenuForCurrentItem() { final hub = _activeHub; if (hub == null || _itemIndex >= hub.items.length) return; @@ -1000,6 +1008,7 @@ class TvBrowseRailState extends State { final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; if (itemIndex == hub.items.length) { return Padding( + key: _itemKeyFor(hub, itemIndex), padding: EdgeInsets.only(right: metrics.itemGap), child: FocusBuilders.buildLockedFocusWrapper( context: context, @@ -1029,6 +1038,7 @@ class TvBrowseRailState extends State { final item = hub.items[itemIndex]; return Padding( + key: _itemKeyFor(hub, itemIndex), padding: EdgeInsets.only(right: metrics.itemGap), child: MouseRegion( onEnter: (_) => _setHoveredItem(hub, itemIndex), diff --git a/test/utils/scroll_utils_test.dart b/test/utils/scroll_utils_test.dart index 4846a72a..c9b5a8bc 100644 --- a/test/utils/scroll_utils_test.dart +++ b/test/utils/scroll_utils_test.dart @@ -270,4 +270,42 @@ void main() { controller.dispose(); }); }); + + group('scrollKeyedChildToHorizontalCenter', () { + testWidgets('centers a keyed child using measured layout bounds', (tester) async { + final controller = ScrollController(); + final itemKey = GlobalKey(); + const viewportWidth = 300.0; + const widths = [90.0, 120.0, 70.0, 180.0, 110.0, 160.0, 100.0]; + const targetIndex = 4; + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: viewportWidth, + height: 80, + child: SingleChildScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < widths.length; i++) + SizedBox(key: i == targetIndex ? itemKey : null, width: widths[i], height: 80, child: Text('$i')), + ], + ), + ), + ), + ), + ); + + scrollKeyedChildToHorizontalCenter(controller, itemKey, animate: false); + await tester.pump(); + + final leadingWidth = widths.take(targetIndex).fold(0, (sum, width) => sum + width); + final targetCenter = leadingWidth + (widths[targetIndex] / 2); + final expected = (targetCenter - (viewportWidth / 2)).clamp(0.0, controller.position.maxScrollExtent); + expect(controller.offset, closeTo(expected, 0.001)); + controller.dispose(); + }); + }); } diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 4886c860..16ea7ab4 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -517,6 +517,73 @@ void main() { expect(focused.last, 'movies:movie_5'); }); + testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + Future pressRight() async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(const Duration(milliseconds: 16)); + } + + final episodes = List.generate( + 153, + (index) => MediaItem( + id: 'episode_${index + 1}', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode ${index + 1}', + parentIndex: 11, + index: index + 1, + thumbPath: '/episode_${index + 1}', + ), + ); + final hub = MediaHub(id: 'detail_season_11', title: 'Season 11', type: 'episode', items: episodes, size: 153); + final serverManager = MultiServerManager(); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + hubs: [hub], + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, + ), + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + for (var i = 0; i < 117; i++) { + await pressRight(); + } + await tester.pumpAndSettle(); + + final targetTitle = find.text('Episode 118'); + expect(targetTitle, findsOneWidget); + final railRect = tester.getRect(find.byType(TvBrowseRail)); + final targetRect = tester.getRect(targetTitle); + expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5)); + expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5)); + }); + testWidgets('resets long-press state when context menu focus receives select key up', (tester) async { final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe'); addTearDown(menuFocusNode.dispose);