import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/plex_hub.dart'; import '../screens/hub_detail_screen.dart'; import 'media_card.dart'; import 'horizontal_scroll_with_arrows.dart'; import 'hub_navigation_controller.dart'; import '../i18n/strings.g.dart'; import 'focus/focus_indicator.dart'; import '../utils/keyboard_utils.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 class HubSection extends StatefulWidget { final PlexHub hub; final IconData icon; final void Function(String)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final bool isInContinueWatching; /// Order for navigation (lower = higher on screen). Default is 1000 for dynamic hubs. final int navigationOrder; const HubSection({ super.key, required this.hub, required this.icon, this.onRefresh, this.onRemoveFromContinueWatching, this.isInContinueWatching = false, this.navigationOrder = 1000, }); @override State createState() => _HubSectionState(); } class _HubSectionState extends State { late final FocusNode _headerFocusNode; bool _headerIsFocused = false; HubNavigationController? _controller; /// Focus nodes for each item in the hub List _itemFocusNodes = []; String? _registeredHubId; int? _registeredItemCount; int? _registeredOrder; String get _hubId => widget.hub.hubIdentifier ?? widget.hub.title; @override void initState() { super.initState(); _headerFocusNode = FocusNode(); _headerFocusNode.addListener(_handleHeaderFocusChange); _createItemFocusNodes(); } void _createItemFocusNodes() { // Create focus nodes for each item _itemFocusNodes = List.generate( widget.hub.items.length, (index) => FocusNode(debugLabel: 'HubItem_${_hubId}_$index'), ); } @override void didChangeDependencies() { super.didChangeDependencies(); _registerWithController(); } @override void didUpdateWidget(HubSection oldWidget) { super.didUpdateWidget(oldWidget); // If items changed, recreate focus nodes if (widget.hub.items.length != _itemFocusNodes.length) { _disposeItemFocusNodes(); _createItemFocusNodes(); } _registerWithController(); } void _unregisterFromController() { if (_controller != null && _registeredHubId != null) { _controller!.unregister(_registeredHubId!); } _registeredHubId = null; _registeredItemCount = null; _registeredOrder = null; } void _registerWithController() { final controller = HubNavigationScope.maybeOf(context); final hubId = _hubId; final itemCount = widget.hub.items.length; final order = widget.navigationOrder; if (controller != _controller) { _unregisterFromController(); _controller = controller; } if (controller == null) return; final registrationChanged = _registeredHubId != hubId || _registeredItemCount != itemCount || _registeredOrder != order; if (registrationChanged) { _unregisterFromController(); controller.register( HubSectionRegistration( hubId: hubId, itemCount: itemCount, focusItem: _focusItem, order: order, ), ); _registeredHubId = hubId; _registeredItemCount = itemCount; _registeredOrder = order; } } void _focusItem(int index) { if (index >= 0 && index < _itemFocusNodes.length) { _itemFocusNodes[index].requestFocus(); } } void _disposeItemFocusNodes() { for (final node in _itemFocusNodes) { node.dispose(); } _itemFocusNodes = []; } @override void dispose() { _unregisterFromController(); _headerFocusNode.removeListener(_handleHeaderFocusChange); _headerFocusNode.dispose(); _disposeItemFocusNodes(); super.dispose(); } void _handleHeaderFocusChange() { if (_headerIsFocused != _headerFocusNode.hasFocus) { setState(() { _headerIsFocused = _headerFocusNode.hasFocus; }); } } void _navigateToHubDetail() { Navigator.push( context, MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)), ); } KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent && widget.hub.more) { if (isKeyboardActivationKey(event.logicalKey)) { _navigateToHubDetail(); return KeyEventResult.handled; } } return KeyEventResult.ignored; } @override Widget build(BuildContext context) { return FocusTraversalGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Hub header Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: Focus( focusNode: _headerFocusNode, onKeyEvent: _handleHeaderKeyEvent, canRequestFocus: widget.hub.more, // Only focusable if has "more" child: FocusIndicator( isFocused: _headerIsFocused && widget.hub.more, borderRadius: 8, child: InkWell( onTap: widget.hub.more ? _navigateToHubDetail : null, borderRadius: BorderRadius.circular(8), focusColor: Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(widget.icon), 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 Icon(Icons.chevron_right, size: 20), ], ], ), ), ), ), ), ), // Hub items (horizontal scroll) if (widget.hub.items.isNotEmpty) LayoutBuilder( builder: (context, constraints) { // Responsive card width based on screen size final screenWidth = constraints.maxWidth; final cardWidth = screenWidth > 1600 ? 220.0 : screenWidth > 1200 ? 200.0 : screenWidth > 800 ? 190.0 : 160.0; // MediaCard has 8px padding on all sides (16px total horizontally) // So actual poster width is cardWidth - 16 final posterWidth = cardWidth - 16; // 2:3 poster aspect ratio (height is 1.5x width) final posterHeight = posterWidth * 1.5; // Container height = poster + padding + spacing + text + focus indicator headroom // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding // + 10px extra for focus indicator border (3px) and scale effect (1.02x) final containerHeight = posterHeight + 46 + 10; return SizedBox( height: containerHeight, child: ClipRect( clipBehavior: Clip.none, child: HorizontalScrollWithArrows( builder: (scrollController) => FocusTraversalGroup( child: ListView.builder( controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, // Allow focus indicator to overflow padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 5, ), itemCount: widget.hub.items.length, itemBuilder: (context, index) { final item = widget.hub.items[index]; return Padding( padding: const EdgeInsets.symmetric( horizontal: 2, ), child: MediaCard( key: Key(item.ratingKey), item: item, width: cardWidth, height: posterHeight, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, forceGridMode: true, isInContinueWatching: widget.isInContinueWatching, focusNode: _itemFocusNodes.length > index ? _itemFocusNodes[index] : null, hubId: _hubId, itemIndex: index, ), ); }, ), ), ), ), ); }, ) 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), ), ), ], ), ); } }