fix(tv): correct long hub row focus scrolling

This commit is contained in:
edde746
2026-05-22 21:32:26 +02:00
parent 048d22cb99
commit cbea1c8d8f
5 changed files with 184 additions and 3 deletions
+55 -1
View File
@@ -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;
}
+14 -2
View File
@@ -125,12 +125,14 @@ class HubSectionState extends State<HubSection> 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<HubSection> 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<HubSection> with MountedSetStateMixin {
}
/// GlobalKeys for MediaCards to access their state (for context menu)
final Map<int, GlobalKey> _itemKeys = {};
final Map<int, GlobalKey<MediaCardState>> _mediaCardKeys = {};
GlobalKey _itemKeyFor(int index) {
return _itemKeys.putIfAbsent(index, () => GlobalKey());
}
GlobalKey<MediaCardState> _getMediaCardKey(int index) {
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
}
@@ -492,6 +502,7 @@ class HubSectionState extends State<HubSection> 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<HubSection> 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),
+10
View File
@@ -295,6 +295,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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 = {};
int _hubIndex = 0;
@@ -656,6 +657,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<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;
@@ -1000,6 +1008,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
final item = hub.items[itemIndex];
return Padding(
key: _itemKeyFor(hub, itemIndex),
padding: EdgeInsets.only(right: metrics.itemGap),
child: MouseRegion(
onEnter: (_) => _setHoveredItem(hub, itemIndex),
+38
View File
@@ -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<double>(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();
});
});
}
+67
View File
@@ -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<void> 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<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,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(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);