The memo-lookup, scroll-budget, skeleton-scheduling sequence was copied in the paginated grid, the browse tab, and hub rows; SkeletonUpgradeScheduler now owns it as realizeBudgeted with per-site skeleton and card builders, keeping the hub's focus salt and padded skeleton. The video player screen held fourteen nullable subscription fields and listed them twice when cancelling; they are two lists (player re-wire, media controls) snapshotted and cleared before cancellation, with the screen-lifetime Apple TV and sleep-timer subscriptions left as named fields. Live seek and subtitle switches open through one helper, and the initial live open applies the shared stream options instead of an inline property write.
815 lines
34 KiB
Dart
815 lines
34 KiB
Dart
import 'dart:math' as math;
|
|
|
|
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/focus_navigation_intent.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import '../services/settings_service.dart';
|
|
import 'settings_builder.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 'media_grid_delegate.dart';
|
|
import 'skeleton_media_card.dart';
|
|
import 'sliver_child_memo.dart';
|
|
import '../utils/scroll_utils.dart';
|
|
import 'horizontal_scroll_with_arrows.dart';
|
|
import 'tv_browse_rail.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}';
|
|
}
|
|
|
|
// Per-step scroll glide; platform-specific, see
|
|
// FocusTheme.navigationScrollDuration. Successive steps retarget the
|
|
// animation so a drag chains into one continuous glide.
|
|
static Duration get _navigationScrollDuration => FocusTheme.navigationScrollDuration();
|
|
final _selectLongPress = DpadSelectLongPressController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_hubFocusNode = LockedFocusRowNode(debugLabel: 'hub_${widget.hub.id}', focusedItemRect: _focusedItemRect);
|
|
_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.id == oldWidget.hub.id) {
|
|
_followFocusedItem(oldWidget.hub.items);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keep visual focus on the media it was on when the hub's items reorder
|
|
/// underneath it — a Continue Watching refresh after playback moves the
|
|
/// just-played item to the front (#1987). The exact item wins; an episode
|
|
/// that left the row follows its series' replacement entry (next episode).
|
|
/// When neither is present the positional clamp above applies unchanged.
|
|
void _followFocusedItem(List<MediaItem> oldItems) {
|
|
if (identical(oldItems, widget.hub.items)) return;
|
|
if (_focusedIndex < 0 || _focusedIndex >= oldItems.length) return;
|
|
final followedIndex = followItemIndex(widget.hub.items, oldItems[_focusedIndex]);
|
|
if (followedIndex == -1 || followedIndex == _focusedIndex) return;
|
|
_focusedIndex = followedIndex;
|
|
widget.focusMemory.remapForHub(_focusMemoryKey, followedIndex);
|
|
if (_hubFocusNode.hasFocus) {
|
|
// didUpdateWidget runs during the build phase; notifying listeners or
|
|
// jumping scroll positions here could setState mid-build upstream.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _focusedIndex != followedIndex || !_hubFocusNode.hasFocus) return;
|
|
_notifyFocusedItemChanged();
|
|
_scrollToIndex(followedIndex, animate: false);
|
|
});
|
|
}
|
|
}
|
|
|
|
@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() {
|
|
// Programmatic focus in touch/pointer mode (e.g. a tab switch handing focus
|
|
// to the first hub) must not scroll the page; only keyboard/D-pad focus
|
|
// scrolls, mirroring FocusableWrapper.autoScroll.
|
|
if (InputModeTracker.currentMode != InputMode.keyboard) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
Scrollable.ensureVisible(
|
|
context,
|
|
alignment: widget.focusScrollAlignment,
|
|
duration: _navigationScrollDuration,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
duration: _navigationScrollDuration,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
if (index >= 0 && index < _totalItemCount) {
|
|
scrollKeyedChildToHorizontalCenter(
|
|
_scrollController,
|
|
_itemKeyFor(index),
|
|
animate: animate,
|
|
isCurrent: () => _focusedIndex == index && index < _totalItemCount,
|
|
duration: _navigationScrollDuration,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
/// Global rect of the selected card, pricing one swipe step by the card's
|
|
/// geometry instead of the row-wide focus node's (see [LockedFocusRowNode]).
|
|
Rect? _focusedItemRect() {
|
|
final context = _itemKeys[_focusedIndex]?.currentContext;
|
|
final box = context?.findRenderObject();
|
|
if (box is! RenderBox || !box.attached || !box.hasSize) return null;
|
|
return box.localToGlobal(Offset.zero) & box.size;
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// TV shelf cards share [TvBrowseRailLayout.cardWidthFor] so the two
|
|
/// remaining HubSection-on-TV surfaces (live TV "What's On", catalog item
|
|
/// related rows) match the rails every neighboring TV screen renders —
|
|
/// including the rail's own wide-card target instead of a local multiplier.
|
|
double _getTvCardWidth(
|
|
BuildContext context,
|
|
double availableWidth,
|
|
int density,
|
|
double leadingPadding,
|
|
bool useWideLayout,
|
|
double gridGap,
|
|
) {
|
|
return TvBrowseRailLayout.cardWidthFor(
|
|
availableWidth: availableWidth,
|
|
density: density,
|
|
useWideLayout: useWideLayout,
|
|
scale: TvLayoutConstants.scaleOf(context),
|
|
horizontalPadding: leadingPadding * 2,
|
|
itemGap: gridGap,
|
|
);
|
|
}
|
|
|
|
@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,
|
|
SettingsService.gridSpacing,
|
|
],
|
|
builder: (context) => LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final svc = SettingsService.instanceOrNull;
|
|
if (svc == null) return const SizedBox.shrink();
|
|
final density = svc.read(SettingsService.libraryDensity);
|
|
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;
|
|
|
|
// 16:9 when every item is wide (episode thumbnails, clips,
|
|
// home videos), or for mixed hubs in episode-thumbnail
|
|
// mode. Clip-only hubs stay wide in the poster modes —
|
|
// same gate as TvBrowseRail — since episodes already fold
|
|
// the mode into usesWideAspectRatio (#2036).
|
|
final useWideLayout =
|
|
hasEpisodes && (!hasNonEpisodes || episodePosterMode == EpisodePosterMode.episodeThumbnail);
|
|
|
|
// Music hubs render square album/artist artwork
|
|
final isSquareHub =
|
|
widget.hub.items.isNotEmpty &&
|
|
widget.hub.items.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
|
|
|
// A hub row is one row of the grid: cells pack with the
|
|
// same gutter the grid uses, so a row and the "see all"
|
|
// grid behind it render the same card at equal width.
|
|
final gridGap = svc.read(SettingsService.gridSpacing).gridGap;
|
|
final cardWidth = isTv && widget.cardSizing == HubCardSizing.shelf
|
|
? _getTvCardWidth(
|
|
context,
|
|
constraints.maxWidth,
|
|
density,
|
|
leadingPadding,
|
|
useWideLayout,
|
|
gridGap,
|
|
)
|
|
: MediaGridDelegate.cellWidth(
|
|
context: context,
|
|
availableWidth: constraints.maxWidth,
|
|
density: density,
|
|
useWideAspectRatio: useWideLayout,
|
|
);
|
|
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
|
|
|
|
// Rendered gap between neighbouring cards. The row has
|
|
// always carried 4px (2px each side) on top of the card's
|
|
// own 3px padding, so Tight stays byte-identical and the
|
|
// wider settings render exactly the grid gutter (#2226).
|
|
final cardGap = math.max(4.0, gridGap);
|
|
final cardPadding = widget.inset
|
|
? EdgeInsets.only(right: cardGap)
|
|
: EdgeInsets.symmetric(horizontal: cardGap / 2);
|
|
|
|
final containerHeight = posterHeight + (isTv ? 48 : 33);
|
|
final focusBorderWidth = FocusTheme.focusBorderWidth;
|
|
final focusExtra = focusBorderWidth * 2; // border on both sides
|
|
_itemExtent = cardWidth + focusExtra + cardGap;
|
|
|
|
// Everything the card closures capture; a change flushes
|
|
// the memo so cached cards can't carry stale geometry.
|
|
final cardEpoch = (
|
|
cardWidth,
|
|
cardGap,
|
|
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: cardPadding,
|
|
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];
|
|
|
|
// Budget fresh inflations while an enclosing
|
|
// scrollable is moving (rows enter on the parent's
|
|
// vertical scroll); skeletons upgrade a frame
|
|
// later.
|
|
return realizeBudgeted(
|
|
_cardMemo,
|
|
context,
|
|
index,
|
|
item,
|
|
epoch: cardEpoch,
|
|
// Focus moves only rebuild the two affected
|
|
// indices instead of the whole realized row.
|
|
salt: isItemFocused,
|
|
keyboardMode: isKeyboardMode,
|
|
skeleton: () => Padding(
|
|
padding: cardPadding,
|
|
child: SizedBox(width: cardWidth, child: const SkeletonMediaCard()),
|
|
),
|
|
build: () => Padding(
|
|
key: _itemKeyFor(index),
|
|
padding: cardPadding,
|
|
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();
|
|
}
|
|
}
|