fix(tv): stabilize long rail focus scrolling

This commit is contained in:
edde746
2026-05-22 23:55:54 +02:00
parent cbea1c8d8f
commit 8ec2795da1
4 changed files with 187 additions and 32 deletions
+3
View File
@@ -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);
+6 -1
View File
@@ -214,7 +214,12 @@ class HubSectionState extends State<HubSection> 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,
);
}
}
+57 -28
View File
@@ -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<TvBrowseRail> {
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<String, ScrollController> _scrollControllers = {};
final ScrollController _verticalController = ScrollController();
final Map<int, GlobalKey> _hubSectionKeys = {};
final Map<String, GlobalKey> _itemKeys = {};
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {};
final Map<String, double> _scaleByHub = {};
int _hubIndex = 0;
int _itemIndex = 0;
double _itemExtent = 260;
double _railLeadingPadding = 0;
List<double> _sectionOffsets = const [];
double _sectionMaxScrollExtent = 0;
Timer? _longPressTimer;
@@ -514,7 +534,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
});
_rememberFocus(hub);
_notifyFocusedItem();
_scrollToItem();
_scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
} else {
widget.onNavigateToSidebar?.call();
}
@@ -529,7 +549,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
});
_rememberFocus(hub);
_notifyFocusedItem();
_scrollToItem();
_scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
}
return KeyEventResult.handled;
}
@@ -644,21 +664,34 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey<MediaCardState>());
}
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
final item = hub.items[itemIndex];
return Padding(
key: _itemKeyFor(hub, itemIndex),
padding: EdgeInsets.only(right: metrics.itemGap),
child: MouseRegion(
onEnter: (_) => _setHoveredItem(hub, itemIndex),
+121 -3
View File
@@ -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 = <String>[];
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<MultiServerProvider>(
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<TvBrowseRailState>(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 {