diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 7515632e..81de171a 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -86,9 +86,12 @@ void scrollKeyedChildToHorizontalCenter( GlobalKey key, { bool animate = true, int maxAttempts = 2, + bool Function()? isCurrent, }) { void schedule(int attempt) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (isCurrent?.call() == false) return; + final context = key.currentContext; if (context == null) { if (attempt < maxAttempts) schedule(attempt + 1); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 1a2bb6e0..9690a710 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -214,7 +214,12 @@ class HubSectionState extends State with MountedSetStateMixin { animate: animate, ); if (index >= 0 && index < _totalItemCount) { - scrollKeyedChildToHorizontalCenter(_scrollController, _itemKeyFor(index), animate: animate); + scrollKeyedChildToHorizontalCenter( + _scrollController, + _itemKeyFor(index), + animate: animate, + isCurrent: () => _focusedIndex == index && index < _totalItemCount, + ); } } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 848ffe3f..8da1d2a9 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -19,7 +19,6 @@ import '../utils/media_image_helper.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/layout_constants.dart'; -import '../utils/scroll_utils.dart'; import 'app_icon.dart'; import 'focus_builders.dart'; import 'horizontal_scroll_with_arrows.dart'; @@ -189,14 +188,33 @@ class TvBrowseRailLayout { return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); } + static double itemExtentForIndex({ + required MediaHub hub, + required int index, + required TvBrowseRailLayoutMetrics metrics, + required double scale, + }) { + if (index == hub.items.length && hub.more) return (132 * scale) + metrics.itemGap; + return metrics.cardWidth + metrics.itemGap; + } + static double scrollOffsetForIndex({ + required MediaHub hub, required int index, required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double maxScrollExtent, + required double scale, }) { - final itemExtent = metrics.cardWidth + metrics.itemGap; - final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2); + final totalCount = hub.items.length + (hub.more ? 1 : 0); + if (totalCount == 0) return 0; + + final clampedIndex = index.clamp(0, totalCount - 1).toInt(); + final normalItemExtent = metrics.cardWidth + metrics.itemGap; + final normalItemsBefore = clampedIndex < hub.items.length ? clampedIndex : hub.items.length; + final leadingOffset = metrics.railEdgePadding + (normalItemsBefore * normalItemExtent); + final targetExtent = itemExtentForIndex(hub: hub, index: clampedIndex, metrics: metrics, scale: scale); + final targetCenter = leadingOffset + (targetExtent / 2); return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble(); } @@ -290,18 +308,20 @@ class TvBrowseRail extends StatefulWidget { class TvBrowseRailState extends State { static const _longPressDuration = Duration(milliseconds: 500); + static const _navigationScrollDuration = Duration(milliseconds: 130); + static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); + static const _scrollCatchUpViewportDistance = 2.5; final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; final ScrollController _verticalController = ScrollController(); final Map _hubSectionKeys = {}; - final Map _itemKeys = {}; final Map> _mediaCardKeys = {}; + final Map _metricsByHub = {}; + final Map _scaleByHub = {}; int _hubIndex = 0; int _itemIndex = 0; - double _itemExtent = 260; - double _railLeadingPadding = 0; List _sectionOffsets = const []; double _sectionMaxScrollExtent = 0; Timer? _longPressTimer; @@ -514,7 +534,7 @@ class TvBrowseRailState extends State { }); _rememberFocus(hub); _notifyFocusedItem(); - _scrollToItem(); + _scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration); } else { widget.onNavigateToSidebar?.call(); } @@ -529,7 +549,7 @@ class TvBrowseRailState extends State { }); _rememberFocus(hub); _notifyFocusedItem(); - _scrollToItem(); + _scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration); } return KeyEventResult.handled; } @@ -644,21 +664,34 @@ class TvBrowseRailState extends State { HubFocusMemory.setForHub(hub.id, _itemIndex); } - void _scrollToItem({bool animate = true}) { + void _scrollToItem({bool animate = true, Duration duration = _navigationScrollDuration}) { final hub = _activeHub; if (hub == null) return; final controller = _scrollControllers[hub.id]; if (controller == null) return; - - scrollListToIndex( - controller, - _itemIndex, - itemExtent: _itemExtent, - leadingPadding: _railLeadingPadding, - animate: animate, + if (controller.positions.length != 1) return; + final metrics = _metricsByHub[hub.id]; + if (metrics == null) return; + final scale = _scaleByHub[hub.id] ?? 1.0; + final position = controller.position; + final viewportWidth = position.viewportDimension; + final maxScrollExtent = position.maxScrollExtent; + if (!viewportWidth.isFinite || !maxScrollExtent.isFinite) return; + final target = TvBrowseRailLayout.scrollOffsetForIndex( + hub: hub, + index: _itemIndex, + metrics: metrics, + viewportWidth: viewportWidth, + maxScrollExtent: maxScrollExtent, + scale: scale, ); - if (_itemIndex >= 0 && _itemIndex < _totalItemCount(hub)) { - scrollKeyedChildToHorizontalCenter(controller, _itemKeyFor(hub, _itemIndex), animate: animate); + + final distance = (position.pixels - target).abs(); + if (distance < 0.5) return; + if (!animate || duration == Duration.zero || distance > viewportWidth * _scrollCatchUpViewportDistance) { + position.jumpTo(target); + } else { + unawaited(position.animateTo(target, duration: duration, curve: Curves.easeOutCubic)); } } @@ -684,10 +717,12 @@ class TvBrowseRailState extends State { scale: scale, ); final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( + hub: hub, index: initialItemIndex, metrics: metrics, viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, + scale: scale, ); return ScrollController(initialScrollOffset: initialScrollOffset); }); @@ -697,10 +732,6 @@ 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; @@ -980,10 +1011,8 @@ class TvBrowseRailState extends State { final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount); final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex; final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex); - if (isActiveHub) { - _railLeadingPadding = metrics.railEdgePadding; - _itemExtent = metrics.cardWidth + metrics.itemGap; - } + _metricsByHub[hub.id] = metrics; + _scaleByHub[hub.id] = scale; return Transform.translate( offset: Offset(-interactionExpansion, 0), @@ -1003,12 +1032,13 @@ class TvBrowseRailState extends State { scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: EdgeInsets.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), + itemExtentBuilder: (itemIndex, _) => + TvBrowseRailLayout.itemExtentForIndex(hub: hub, index: itemIndex, metrics: metrics, scale: scale), itemCount: totalCount, itemBuilder: (context, itemIndex) { 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, @@ -1038,7 +1068,6 @@ 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/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 16ea7ab4..c34dd4df 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -437,9 +437,14 @@ void main() { episodePosterMode: EpisodePosterMode.episodeThumbnail, scale: scale, ); - final itemExtent = metrics.cardWidth + metrics.itemGap; - final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2); - final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent); + final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex( + hub: episodeHub, + index: 5, + metrics: metrics, + viewportWidth: position.viewportDimension, + maxScrollExtent: position.maxScrollExtent, + scale: scale, + ); expect(activeHubIds.last, episodeHub.id); expect(parentRebuilds, greaterThan(0)); @@ -582,6 +587,119 @@ void main() { final targetRect = tester.getRect(targetTitle); expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5)); expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5)); + + final position = _activeRailPosition(tester); + final size = tester.view.physicalSize / tester.view.devicePixelRatio; + final scale = TvBrowseRailLayout.scaleForSize(size); + final metrics = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale), + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex( + hub: hub, + index: 117, + metrics: metrics, + viewportWidth: position.viewportDimension, + maxScrollExtent: position.maxScrollExtent, + scale: scale, + ); + expect(position.pixels, closeTo(expectedOffset, 0.1)); + }); + + testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final focusedItemIds = []; + const episodeCount = 500; + const targetIndex = 419; + final episodes = List.generate( + episodeCount, + (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: episodeCount, + ); + 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, + onFocusedItemChanged: (item) => focusedItemIds.add(item.id), + episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, + ), + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + for (var i = 0; i < targetIndex - 1; i++) { + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect(focusedItemIds.last, 'episode_${targetIndex + 1}'); + final targetTitle = find.text('Episode ${targetIndex + 1}'); + 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)); + + final position = _activeRailPosition(tester); + final size = tester.view.physicalSize / tester.view.devicePixelRatio; + final scale = TvBrowseRailLayout.scaleForSize(size); + final metrics = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale), + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex( + hub: hub, + index: targetIndex, + metrics: metrics, + viewportWidth: position.viewportDimension, + maxScrollExtent: position.maxScrollExtent, + scale: scale, + ); + expect(position.pixels, closeTo(expectedOffset, 0.1)); }); testWidgets('resets long-press state when context menu focus receives select key up', (tester) async {