fix(tv): stabilize long rail focus scrolling
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user