Files
plezy/lib/widgets/hub_section.dart
T
edde746 27acbaf435 feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
2026-07-29 06:47:54 +02:00

732 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/dpad_navigator.dart';
import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/layout_constants.dart';
import '../utils/platform_detector.dart';
import '../theme/mono_tokens.dart';
import '../focus/locked_hub_controller.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../screens/hub_detail_screen.dart';
import '../utils/media_navigation_helper.dart';
import 'card_inflation_budget.dart';
import 'focus_builders.dart';
import 'media_card.dart';
import 'skeleton_media_card.dart';
import 'sliver_child_memo.dart';
import '../utils/scroll_utils.dart';
import 'horizontal_scroll_with_arrows.dart';
import '../i18n/strings.g.dart';
enum HubCardSizing {
/// Larger cards optimized for top-level TV shelves.
shelf,
/// Grid-equivalent cards for shelves embedded in dense detail content.
grid,
}
/// Shared hub section widget used in both discover and library screens
/// Displays a hub title with icon and a horizontal scrollable list of items
///
/// Uses a "locked" focus pattern where:
/// - A single Focus widget at the hub level intercepts ALL arrow keys
/// - Visual focus index is tracked in state (not Flutter's focus system)
/// - Children render focus visuals based on the passed index
/// - Focus never "escapes" to random elements
class HubSection extends StatefulWidget {
final MediaHub hub;
final HubFocusMemory focusMemory;
final IconData icon;
final void Function(MediaItem source)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool isInContinueWatching;
final bool usesContinueWatchingAction;
final bool showServerName;
final Future<List<MediaItem>> Function()? loadMoreItems;
/// Provider-reported result count shown alongside the existing hub title.
final int? totalResults;
/// Reports the current focused media item. Used by TV spotlight layouts.
final ValueChanged<MediaItem>? onFocusedItemChanged;
/// Overrides the default media navigation for an item.
final ValueChanged<MediaItem>? onItemTap;
/// Overrides the standard media context menu for an item.
final ValueChanged<MediaItem>? onItemLongPress;
/// Callback for vertical navigation (up/down). Return true if handled.
final bool Function(bool isUp)? onVerticalNavigation;
/// Called when the user presses BACK.
/// Used to navigate focus back to the tab bar.
final VoidCallback? onBack;
/// Called when the user presses UP while at the topmost item (first hub).
/// Used to navigate focus to the tab bar.
final VoidCallback? onNavigateUp;
/// Called when the user presses LEFT while at the leftmost item (index 0).
/// Used to navigate focus to the sidebar.
final VoidCallback? onNavigateToSidebar;
/// When true, removes internal horizontal padding (header + list).
/// Use when the parent already provides edge spacing (e.g. inside Padding(16)).
final bool inset;
/// Controls whether cards follow top-level shelf or grid geometry.
final HubCardSizing cardSizing;
/// Overrides the global episode artwork mode for this hub.
final EpisodePosterMode? episodePosterModeOverride;
/// Vertical viewport alignment when this hub is focused.
final double focusScrollAlignment;
const HubSection({
super.key,
required this.hub,
required this.focusMemory,
required this.icon,
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isInContinueWatching = false,
bool? usesContinueWatchingAction,
this.showServerName = false,
this.loadMoreItems,
this.totalResults,
this.onFocusedItemChanged,
this.onItemTap,
this.onItemLongPress,
this.onVerticalNavigation,
this.onBack,
this.onNavigateUp,
this.onNavigateToSidebar,
this.inset = false,
this.cardSizing = HubCardSizing.shelf,
this.episodePosterModeOverride,
this.focusScrollAlignment = 0.3,
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@override
State<HubSection> createState() => HubSectionState();
}
class HubSectionState extends State<HubSection> with MountedSetStateMixin, SkeletonUpgradeScheduler {
late FocusNode _hubFocusNode;
final ScrollController _scrollController = ScrollController();
/// Current visual focus index (not tied to Flutter's focus system)
int _focusedIndex = 0;
/// Reuses card widgets across rebuilds (parent setStates, focus moves) so
/// only changed indices rebuild instead of every realized card in the row.
final SliverChildMemo<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
double _itemExtent = 0;
double _leadingPaddingFor(bool isTv) => widget.inset
? 0.0
: isTv
? TvLayoutConstants.shelfHorizontalInset
: 12.0;
double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV());
String get _focusMemoryKey {
final serverId = widget.hub.serverId;
return serverId == null ? widget.hub.id : '$serverId:${widget.hub.id}';
}
final _selectLongPress = DpadSelectLongPressController();
@override
void initState() {
super.initState();
_hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.id}');
_hubFocusNode.addListener(_onFocusChange);
}
/// Total item count including the optional "View All" card
int get _totalItemCount => widget.hub.items.length + (widget.hub.more ? 1 : 0);
@override
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 || 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 || widget.hub.more != oldWidget.hub.more) {
final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1;
if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex;
}
}
}
@override
void dispose() {
_selectLongPress.dispose();
_hubFocusNode.removeListener(_onFocusChange);
_hubFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onFocusChange() {
// Reset long press state when focus is lost
if (!_hubFocusNode.hasFocus) {
_selectLongPress.reset();
} else {
_notifyFocusedItemChanged();
}
// ignore: no-empty-block - setState triggers rebuild to update focus styling
setStateIfMounted(() {});
}
/// Request focus on this hub at a specific item index
void requestFocusAt(int index) {
if (_totalItemCount == 0) return;
final clamped = index.clamp(0, _totalItemCount - 1).toInt();
_focusedIndex = clamped;
// Remember this position for this specific hub
widget.focusMemory.setForHub(_focusMemoryKey, clamped);
_notifyFocusedItemChanged();
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
// ignore: no-empty-block - setState triggers rebuild to update focus styling
setStateIfMounted(() {});
_scrollHubIntoView();
}
/// Request focus using the stored memory for this hub
void requestFocusFromMemory() {
final index = widget.focusMemory.getForHub(_focusMemoryKey, _totalItemCount);
requestFocusAt(index);
}
/// Scroll this hub into view in the parent scroll view
void _scrollHubIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Scrollable.ensureVisible(
context,
alignment: widget.focusScrollAlignment,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
/// Get the number of items in this hub
int get itemCount => _totalItemCount;
/// Scroll to center the item at the given index
void _scrollToIndex(int index, {bool animate = true}) {
scrollListToIndex(
_scrollController,
index,
itemExtent: _itemExtent,
leadingPadding: _leadingPadding,
animate: animate,
);
if (index >= 0 && index < _totalItemCount) {
scrollKeyedChildToHorizontalCenter(
_scrollController,
_itemKeyFor(index),
animate: animate,
isCurrent: () => _focusedIndex == index && index < _totalItemCount,
);
}
}
/// Handle ALL key events at the hub level
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final selectResult = _selectLongPress.handleKeyEvent(
event,
isOwnerActive: () => mounted,
onShortPress: _activateCurrentItem,
onLongPress: _showContextMenuForCurrentItem,
);
if (selectResult != KeyEventResult.ignored) return selectResult;
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
}
// Handle key down and repeat events
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final totalCount = _totalItemCount;
if (totalCount == 0) return KeyEventResult.ignored;
// Left: move to previous item, or navigate to sidebar at left edge
if (key.isLeftKey) {
if (_focusedIndex > 0) {
setState(() {
_focusedIndex--;
});
widget.focusMemory.setForHub(_focusMemoryKey, _focusedIndex);
_notifyFocusedItemChanged();
_scrollToIndex(_focusedIndex);
} else if (widget.onNavigateToSidebar != null) {
// At leftmost item: navigate to sidebar
widget.onNavigateToSidebar!();
}
// Always consume to prevent focus escape
return KeyEventResult.handled;
}
// Right: move to next item, ALWAYS consume to prevent escape
if (key.isRightKey) {
if (_focusedIndex < totalCount - 1) {
setState(() {
_focusedIndex++;
});
widget.focusMemory.setForHub(_focusMemoryKey, _focusedIndex);
_notifyFocusedItemChanged();
_scrollToIndex(_focusedIndex);
}
return KeyEventResult.handled;
}
// Up/Down: delegate to parent for vertical hub navigation, ALWAYS consume
if (key.isUpKey) {
final handled = widget.onVerticalNavigation?.call(true) ?? false;
// If not handled (at top boundary) and we have onNavigateUp, call it
if (!handled && widget.onNavigateUp != null) {
widget.onNavigateUp!();
}
return KeyEventResult.handled;
}
if (key.isDownKey) {
widget.onVerticalNavigation?.call(false);
return KeyEventResult.handled;
}
// Context menu key: show context menu
if (key.isContextMenuKey) {
_showContextMenuForCurrentItem();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// 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>());
}
void _notifyFocusedItemChanged() {
if (_focusedIndex < 0 || _focusedIndex >= widget.hub.items.length) return;
widget.onFocusedItemChanged?.call(widget.hub.items[_focusedIndex]);
}
void _activateCurrentItem() {
if (_focusedIndex == widget.hub.items.length && widget.hub.more) {
_navigateToHubDetail(context);
return;
}
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
if (widget.onItemTap case final onItemTap?) {
onItemTap(item);
return;
}
_navigateToItem(item);
}
void _showContextMenuForCurrentItem() {
// No context menu for the "View All" card
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
if (widget.onItemLongPress case final onItemLongPress?) {
onItemLongPress(item);
return;
}
_mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu();
}
Future<void> _navigateToItem(MediaItem item) async {
await navigateToMediaItem(
context,
item,
onRefresh: widget.onRefresh,
playDirectly: widget.usesContinueWatchingAction,
);
}
void _navigateToHubDetail(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HubDetailScreen(
hub: widget.hub,
loadItems: widget.loadMoreItems,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
),
),
);
}
double _getTvCardWidth(double availableWidth, int density, double leadingPadding) {
final f = LibraryDensity.factor(density);
final targetCards = 7.0 - (f * 2.0);
final usableWidth = (availableWidth - (leadingPadding * 2)).clamp(1.0, double.infinity);
return (usableWidth / targetCards).clamp(210.0, 340.0);
}
@override
Widget build(BuildContext context) {
final hasFocus = _hubFocusNode.hasFocus;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final isTv = PlatformDetector.isTV();
final leadingPadding = _leadingPaddingFor(isTv);
final titleStyle = Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontSize: isTv ? 26 : null, fontWeight: isTv ? FontWeight.w700 : null);
return Padding(
padding: .only(
bottom: isTv && !widget.inset && widget.cardSizing == HubCardSizing.shelf
? TvLayoutConstants.shelfVerticalGap
: 0,
),
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
// Hub header (NOT focusable - titles should not be focusable)
Padding(
padding: widget.inset
? EdgeInsets.symmetric(vertical: isTv ? 6 : 2)
: EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2),
child: ExcludeFocus(
child: InkWell(
mouseCursor: widget.hub.more ? SystemMouseCursors.click : MouseCursor.defer,
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: widget.inset
? const EdgeInsets.symmetric(vertical: 2)
: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: .min,
children: [
AppIcon(widget.icon, fill: 1, size: isTv ? 28 : null),
SizedBox(width: isTv ? 12 : 8),
Flexible(
child: Text(widget.hub.title, style: titleStyle, overflow: .ellipsis, maxLines: 1),
),
if (widget.totalResults case final totalResults?) ...[
SizedBox(width: isTv ? 12 : 8),
Text(
t.explore.totalResults(n: totalResults),
maxLines: 1,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.62),
fontSize: isTv ? 17 : null,
fontWeight: FontWeight.w600,
),
),
],
if (widget.showServerName && widget.hub.serverName != null) ...[
const SizedBox(width: 8),
Text(
'•',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
),
),
const SizedBox(width: 8),
Text(
widget.hub.serverName!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
),
),
],
if (widget.hub.more && !isKeyboardMode) ...[
const SizedBox(width: 4),
AppIcon(Symbols.chevron_right_rounded, fill: 1, size: isTv ? 26 : 20),
],
],
),
),
),
),
),
if (widget.hub.items.isNotEmpty)
Focus(
focusNode: _hubFocusNode,
onKeyEvent: _handleKeyEvent,
child: SettingsBuilder(
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode],
builder: (context) => LayoutBuilder(
builder: (context, constraints) {
final svc = SettingsService.instanceOrNull;
if (svc == null) return const SizedBox.shrink();
final density = svc.read(SettingsService.libraryDensity);
final baseCardWidth = isTv && widget.cardSizing == HubCardSizing.shelf
? _getTvCardWidth(constraints.maxWidth, density, leadingPadding)
: GridSizeCalculator.getCellWidth(constraints.maxWidth, context, density);
final EpisodePosterMode episodePosterMode =
widget.episodePosterModeOverride ?? svc.read(SettingsService.episodePosterMode);
final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
final isMixedHub = hasEpisodes && hasNonEpisodes;
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
// Music hubs render square album/artist artwork
final isSquareHub =
widget.hub.items.isNotEmpty &&
widget.hub.items.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
// Card dimensions based on hub type
const wideCardMultiplier = 1.5;
final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth;
final posterWidth = cardWidth - 6; // 3px padding on each side
final posterHeight = useWideLayout
? posterWidth *
(9 / 16) // 16:9 for wide layout
: isSquareHub
? posterWidth // 1:1 for music artwork
: posterWidth * 1.5; // 2:3 for poster layout
final containerHeight = posterHeight + (isTv ? 48 : 33);
final focusBorderWidth = FocusTheme.focusBorderWidth;
final focusExtra = focusBorderWidth * 2; // border on both sides
_itemExtent = cardWidth + focusExtra + 4;
// Everything the card closures capture; a change flushes
// the memo so cached cards can't carry stale geometry.
final cardEpoch = (
cardWidth,
posterHeight,
useWideLayout,
isMixedHub,
episodePosterMode,
isKeyboardMode,
widget.inset,
widget.isInContinueWatching,
widget.usesContinueWatchingAction,
);
return SizedBox(
height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
// On touch, don't pre-inflate off-screen cards when a row
// enters the viewport — the default 250px realizes 2+ extra
// cards per side in the same frame, fattening the row-entry
// spike. Cards inflate as they scroll in instead (a few ms
// each). TV keeps the default: d-pad animateTo benefits from
// the prefetch and TV rows inflate via the focus path anyway.
scrollCacheExtent: isTv ? null : const ScrollCacheExtent.pixels(0),
padding: widget.inset
? EdgeInsets.symmetric(vertical: isTv ? 6 : 2)
: EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2),
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
itemBuilder: (context, index) {
final isItemFocused = hasFocus && index == _focusedIndex;
if (index == widget.hub.items.length) {
return Padding(
key: _itemKeyFor(index),
padding: widget.inset
? const EdgeInsets.only(right: 4)
: const EdgeInsets.symmetric(horizontal: 2),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isItemFocused,
onTap: () {
_onItemTapped(index);
_navigateToHubDetail(context);
},
child: SizedBox(
width: isTv ? 118 : 80,
height: containerHeight - 10,
child: Center(
child: Column(
mainAxisSize: .min,
children: [
AppIcon(
Symbols.arrow_forward_rounded,
size: isTv ? 42 : 32,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 4),
Text(
t.common.viewAll,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: isTv ? 16 : null,
),
),
],
),
),
),
),
);
}
final item = widget.hub.items[index];
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch, salt: isItemFocused);
if (cached != null) return cached;
// Budget fresh inflations while an enclosing
// scrollable is moving (rows enter on the parent's
// vertical scroll); skeletons upgrade a frame
// later. Keyboard mode is exempt — skeletons
// aren't focus targets.
if (!isKeyboardMode &&
CardInflationBudget.isScrollingContext(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return Padding(
padding: widget.inset
? const EdgeInsets.only(right: 4)
: const EdgeInsets.symmetric(horizontal: 2),
child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()),
);
}
return _cardMemo.widgetFor(
index,
item,
epoch: cardEpoch,
// Focus moves only rebuild the two affected
// indices instead of the whole realized row.
salt: isItemFocused,
build: () => Padding(
key: _itemKeyFor(index),
padding: widget.inset
? const EdgeInsets.only(right: 4)
: const EdgeInsets.symmetric(horizontal: 2),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isItemFocused,
// Pointer/touch taps never reach these: MediaCard's own
// tap region is deeper in the tree and always wins the
// gesture arena. Passing null lets the wrapper collapse
// to the bare card outside keyboard mode instead of
// building a second dead gesture-detector stack per card.
onTap: isKeyboardMode ? () => _onItemTapped(index) : null,
onLongPress: isKeyboardMode
? () {
_onItemTapped(index);
_mediaCardKeys[index]?.currentState?.showContextMenu();
}
: null,
delegateFocusBorder: true,
child: MediaCard(
key: _getMediaCardKey(index),
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
onTap: widget.onItemTap == null
? null
: () {
_onItemTapped(index);
widget.onItemTap!(item);
},
onLongPress: widget.onItemLongPress == null
? null
: () {
_onItemTapped(index);
widget.onItemLongPress!(item);
},
forceGridMode: true,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
mixedHubContext: isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
),
),
);
},
),
),
);
},
),
),
)
else
Padding(
padding: widget.inset
? const EdgeInsets.symmetric(vertical: 8)
: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(t.messages.noItemsAvailable, style: Theme.of(context).textTheme.bodySmall),
),
],
),
);
}
void _onItemTapped(int index) {
if (_totalItemCount == 0) return;
final clamped = index.clamp(0, _totalItemCount - 1).toInt();
setState(() {
_focusedIndex = clamped;
});
widget.focusMemory.setForHub(_focusMemoryKey, clamped);
_notifyFocusedItemChanged();
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
}
}