import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../theme/mono_tokens.dart'; import '../utils/layout_constants.dart'; import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; import 'media_card.dart'; import 'horizontal_scroll_with_arrows.dart'; import '../i18n/strings.g.dart'; /// 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 PlexHub hub; final IconData icon; final void Function(String)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final bool isInContinueWatching; /// 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; const HubSection({ super.key, required this.hub, required this.icon, this.onRefresh, this.onRemoveFromContinueWatching, this.isInContinueWatching = false, this.onVerticalNavigation, this.onBack, }); @override State createState() => HubSectionState(); } class HubSectionState extends State { late FocusNode _hubFocusNode; final ScrollController _scrollController = ScrollController(); /// Current visual focus index (not tied to Flutter's focus system) int _focusedIndex = 0; /// Item extent for scroll calculations double _itemExtent = 0; static const double _leadingPadding = 12.0; @override void initState() { super.initState(); _hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.hubKey}'); _hubFocusNode.addListener(_onFocusChange); } @override void didUpdateWidget(HubSection oldWidget) { super.didUpdateWidget(oldWidget); // Clamp focus index if item count changed if (widget.hub.items.length != oldWidget.hub.items.length) { final maxIndex = widget.hub.items.isEmpty ? 0 : widget.hub.items.length - 1; if (_focusedIndex > maxIndex) { _focusedIndex = maxIndex; } } } @override void dispose() { _hubFocusNode.removeListener(_onFocusChange); _hubFocusNode.dispose(); _scrollController.dispose(); super.dispose(); } void _onFocusChange() { // Rebuild to update visual focus state if (mounted) setState(() {}); } /// Request focus on this hub at a specific item index void requestFocusAt(int index) { if (widget.hub.items.isEmpty) return; final clamped = index.clamp(0, widget.hub.items.length - 1); _focusedIndex = clamped; // Remember this position for this specific hub HubFocusMemory.setForHub(widget.hub.hubKey, clamped); _scrollToIndex(clamped); _hubFocusNode.requestFocus(); if (mounted) setState(() {}); // Scroll the hub into view in the parent scroll view _scrollHubIntoView(); } /// Request focus using the stored memory for this hub void requestFocusFromMemory() { final index = HubFocusMemory.getForHub( widget.hub.hubKey, widget.hub.items.length, ); requestFocusAt(index); } /// Scroll this hub into view in the parent scroll view void _scrollHubIntoView() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; Scrollable.ensureVisible( context, alignment: 0.3, // Position hub near top third of viewport duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); }); } /// Check if this hub currently has focus bool get hasFocusedItem => _hubFocusNode.hasFocus; /// Get the number of items in this hub int get itemCount => widget.hub.items.length; /// Scroll to center the item at the given index void _scrollToIndex(int index, {bool animate = true}) { if (!_scrollController.hasClients || _itemExtent <= 0) return; final viewport = _scrollController.position.viewportDimension; final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); final desiredOffset = (targetCenter - (viewport / 2)).clamp( 0.0, _scrollController.position.maxScrollExtent, ); if (animate) { _scrollController.animateTo( desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } else { _scrollController.jumpTo(desiredOffset); } } /// Handle ALL key events at the hub level KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { // Handle key down and repeat events if (!event.isActionable) { return KeyEventResult.ignored; } final key = event.logicalKey; final itemCount = widget.hub.items.length; if (itemCount == 0) return KeyEventResult.ignored; // Left: move to previous item, ALWAYS consume to prevent escape if (key.isLeftKey) { if (_focusedIndex > 0) { _focusedIndex--; HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); _scrollToIndex(_focusedIndex); setState(() {}); } return KeyEventResult.handled; } // Right: move to next item, ALWAYS consume to prevent escape if (key.isRightKey) { if (_focusedIndex < itemCount - 1) { _focusedIndex++; HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); _scrollToIndex(_focusedIndex); setState(() {}); } return KeyEventResult.handled; } // Up/Down: delegate to parent for vertical hub navigation, ALWAYS consume if (key.isUpKey) { widget.onVerticalNavigation?.call(true); return KeyEventResult.handled; } if (key.isDownKey) { widget.onVerticalNavigation?.call(false); return KeyEventResult.handled; } // Select: activate the current item if (key.isSelectKey) { _activateCurrentItem(); return KeyEventResult.handled; } // Context menu key: show context menu if (key.isContextMenuKey) { _showContextMenuForCurrentItem(); return KeyEventResult.handled; } // Back key: navigate to tab bar if (key.isBackKey && widget.onBack != null) { widget.onBack!(); return KeyEventResult.handled; } return KeyEventResult.ignored; } /// GlobalKeys for MediaCards to access their state (for context menu) final Map> _mediaCardKeys = {}; GlobalKey _getMediaCardKey(int index) { return _mediaCardKeys.putIfAbsent(index, () => GlobalKey()); } void _activateCurrentItem() { if (_focusedIndex >= widget.hub.items.length) return; final item = widget.hub.items[_focusedIndex]; _navigateToItem(item); } void _showContextMenuForCurrentItem() { _mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu(); } Future _navigateToItem(dynamic item) async { await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); } void _navigateToHubDetail(BuildContext context) { Navigator.push( context, MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)), ); } @override Widget build(BuildContext context) { final hasFocus = _hubFocusNode.hasFocus; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Hub header (NOT focusable - titles should not be focusable) Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: ExcludeFocus( child: InkWell( onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ AppIcon(widget.icon, fill: 1), const SizedBox(width: 8), Expanded( child: Text( widget.hub.title, style: Theme.of(context).textTheme.titleLarge, overflow: TextOverflow.ellipsis, maxLines: 1, ), ), if (widget.hub.more) ...[ const SizedBox(width: 4), const AppIcon( Symbols.chevron_right_rounded, fill: 1, size: 20, ), ], ], ), ), ), ), ), // Hub items with locked focus control if (widget.hub.items.isNotEmpty) Focus( focusNode: _hubFocusNode, onKeyEvent: _handleKeyEvent, child: LayoutBuilder( builder: (context, constraints) { // Responsive card width based on screen size final screenWidth = constraints.maxWidth; final cardWidth = ScreenBreakpoints.isLargeDesktop(screenWidth) ? 220.0 : ScreenBreakpoints.isDesktop(screenWidth) ? 200.0 : ScreenBreakpoints.isWideTablet(screenWidth) ? 190.0 : 160.0; // Store item extent for scroll calculations _itemExtent = cardWidth + 4; // 4px total horizontal padding // MediaCard has 8px padding on all sides (16px total horizontally) final posterWidth = cardWidth - 16; // 2:3 poster aspect ratio final posterHeight = posterWidth * 1.5; // Container height calculation final containerHeight = posterHeight + 66; return SizedBox( height: containerHeight, child: HorizontalScrollWithArrows( controller: _scrollController, builder: (scrollController) => ListView.builder( controller: scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 5, ), itemCount: widget.hub.items.length, itemBuilder: (context, index) { final item = widget.hub.items[index]; final isItemFocused = hasFocus && index == _focusedIndex; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: _LockedHubItemWrapper( isFocused: isItemFocused, onTap: () => _onItemTapped(index), onLongPress: () => _mediaCardKeys[index] ?.currentState ?.showContextMenu(), child: MediaCard( key: _getMediaCardKey(index), item: item, width: cardWidth, height: posterHeight, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, forceGridMode: true, isInContinueWatching: widget.isInContinueWatching, ), ), ); }, ), ), ); }, ), ) else Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( t.messages.noItemsAvailable, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: Colors.grey), ), ), ], ); } /// Called when an item is tapped (mouse/touch) void _onItemTapped(int index) { // Update focus to tapped item and request hub focus _focusedIndex = index; HubFocusMemory.setForHub(widget.hub.hubKey, index); _hubFocusNode.requestFocus(); setState(() {}); } } /// Wrapper that provides visual focus decoration without using Flutter's focus system. class _LockedHubItemWrapper extends StatelessWidget { final bool isFocused; final Widget child; final VoidCallback? onTap; final VoidCallback? onLongPress; const _LockedHubItemWrapper({ required this.isFocused, required this.child, this.onTap, this.onLongPress, }); @override Widget build(BuildContext context) { return FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isFocused, onTap: onTap, onLongPress: onLongPress, child: child, ); } }