Files
plezy/lib/widgets/hub_section.dart
T

532 lines
20 KiB
Dart
Raw Normal View History

2026-01-23 20:24:20 +01:00
import 'dart:async';
2025-11-16 20:53:59 +01:00
import 'package:flutter/material.dart';
2026-01-23 20:24:20 +01:00
import 'package:flutter/services.dart';
2025-12-12 15:57:22 +01:00
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
2026-01-08 14:42:49 +01:00
import 'package:provider/provider.dart';
2025-12-07 13:57:52 +01:00
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
2026-01-24 21:40:02 +01:00
import '../focus/key_event_utils.dart';
2026-01-08 14:42:49 +01:00
import '../providers/settings_provider.dart';
import '../services/settings_service.dart' show EpisodePosterMode, LibraryDensity;
2025-12-15 01:16:22 +01:00
import '../theme/mono_tokens.dart';
2025-12-13 00:16:50 +01:00
import '../utils/layout_constants.dart';
2025-12-07 13:57:52 +01:00
import '../focus/locked_hub_controller.dart';
2025-11-16 20:53:59 +01:00
import '../models/plex_hub.dart';
import '../screens/hub_detail_screen.dart';
2025-12-07 15:00:28 +01:00
import '../utils/media_navigation_helper.dart';
import 'focus_builders.dart';
2025-11-16 20:53:59 +01:00
import 'media_card.dart';
import '../utils/scroll_utils.dart';
2025-11-16 20:53:59 +01:00
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
2025-12-07 13:57:52 +01:00
///
/// 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 {
2025-11-16 20:53:59 +01:00
final PlexHub hub;
final IconData icon;
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool isInContinueWatching;
2026-01-14 14:17:56 +01:00
final bool showServerName;
2025-11-16 20:53:59 +01:00
2025-12-07 13:57:52 +01:00
/// 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;
2026-01-25 01:43:10 +01:00
/// Called when the user presses LEFT while at the leftmost item (index 0).
/// Used to navigate focus to the sidebar.
final VoidCallback? onNavigateToSidebar;
2025-11-16 20:53:59 +01:00
const HubSection({
super.key,
required this.hub,
required this.icon,
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isInContinueWatching = false,
2026-01-14 14:17:56 +01:00
this.showServerName = false,
2025-12-07 13:57:52 +01:00
this.onVerticalNavigation,
this.onBack,
this.onNavigateUp,
2026-01-25 01:43:10 +01:00
this.onNavigateToSidebar,
2025-11-16 20:53:59 +01:00
});
2025-12-07 13:57:52 +01:00
@override
State<HubSection> createState() => HubSectionState();
}
class HubSectionState extends State<HubSection> {
2026-01-23 20:24:20 +01:00
static const _longPressDuration = Duration(milliseconds: 500);
2025-12-07 13:57:52 +01:00
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;
2026-01-23 20:24:20 +01:00
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
2025-12-07 13:57:52 +01:00
@override
void initState() {
super.initState();
2025-12-07 15:00:28 +01:00
_hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.hubKey}');
2025-12-07 13:57:52 +01:00
_hubFocusNode.addListener(_onFocusChange);
}
/// Total item count including the optional "View All" card
int get _totalItemCount => widget.hub.items.length + (widget.hub.more ? 1 : 0);
2025-12-07 13:57:52 +01:00
@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 = _totalItemCount == 0 ? 0 : _totalItemCount - 1;
2025-12-07 13:57:52 +01:00
if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex;
}
}
}
@override
void dispose() {
2026-01-23 20:24:20 +01:00
_longPressTimer?.cancel();
2025-12-07 13:57:52 +01:00
_hubFocusNode.removeListener(_onFocusChange);
_hubFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onFocusChange() {
2026-01-23 20:24:20 +01:00
// Reset long press state when focus is lost
if (!_hubFocusNode.hasFocus) {
_longPressTimer?.cancel();
_isSelectKeyDown = false;
_longPressTriggered = false;
}
2026-02-15 03:14:04 +01:00
// ignore: no-empty-block - setState triggers rebuild to update focus styling
2025-12-07 13:57:52 +01:00
if (mounted) setState(() {});
}
/// Request focus on this hub at a specific item index
void requestFocusAt(int index) {
if (_totalItemCount == 0) return;
2025-12-07 13:57:52 +01:00
final clamped = index.clamp(0, _totalItemCount - 1);
2025-12-07 13:57:52 +01:00
_focusedIndex = clamped;
// Remember this position for this specific hub
HubFocusMemory.setForHub(widget.hub.hubKey, clamped);
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
2026-02-15 03:14:04 +01:00
// ignore: no-empty-block - setState triggers rebuild to update focus styling
2025-12-07 13:57:52 +01:00
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, _totalItemCount);
2025-12-07 13:57:52 +01:00
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 => _totalItemCount;
2025-12-07 13:57:52 +01:00
/// 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,
);
2025-12-07 13:57:52 +01:00
}
/// Handle ALL key events at the hub level
2026-02-15 03:14:04 +01:00
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
2026-01-23 20:24:20 +01:00
final key = event.logicalKey;
if (key.isSelectKey) {
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
_longPressTriggered = false;
_longPressTimer?.cancel();
_longPressTimer = Timer(_longPressDuration, () {
if (!mounted) return;
if (_isSelectKeyDown) {
_longPressTriggered = true;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_showContextMenuForCurrentItem();
}
});
}
return KeyEventResult.handled;
} else if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) {
_activateCurrentItem();
}
_isSelectKeyDown = false;
_longPressTriggered = false;
return KeyEventResult.handled;
}
}
2026-01-24 21:40:02 +01:00
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
}
2025-12-07 13:57:52 +01:00
// Handle key down and repeat events
2025-12-14 21:03:11 +01:00
if (!event.isActionable) {
2025-12-07 13:57:52 +01:00
return KeyEventResult.ignored;
}
final totalCount = _totalItemCount;
if (totalCount == 0) return KeyEventResult.ignored;
2025-12-07 13:57:52 +01:00
2026-01-25 01:43:10 +01:00
// Left: move to previous item, or navigate to sidebar at left edge
2025-12-07 13:57:52 +01:00
if (key.isLeftKey) {
if (_focusedIndex > 0) {
2026-02-15 03:14:04 +01:00
setState(() {
_focusedIndex--;
});
2025-12-07 13:57:52 +01:00
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_scrollToIndex(_focusedIndex);
2026-01-25 01:43:10 +01:00
} else if (widget.onNavigateToSidebar != null) {
// At leftmost item: navigate to sidebar
widget.onNavigateToSidebar!();
2025-12-07 13:57:52 +01:00
}
2026-01-25 01:43:10 +01:00
// Always consume to prevent focus escape
2025-12-07 13:57:52 +01:00
return KeyEventResult.handled;
}
// Right: move to next item, ALWAYS consume to prevent escape
if (key.isRightKey) {
if (_focusedIndex < totalCount - 1) {
2026-02-15 03:14:04 +01:00
setState(() {
_focusedIndex++;
});
2025-12-07 13:57:52 +01:00
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_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!();
}
2025-12-07 13:57:52 +01:00
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;
}
2025-12-07 22:51:55 +01:00
/// GlobalKeys for MediaCards to access their state (for context menu)
final Map<int, GlobalKey<MediaCardState>> _mediaCardKeys = {};
2025-12-07 13:57:52 +01:00
2025-12-07 22:51:55 +01:00
GlobalKey<MediaCardState> _getMediaCardKey(int index) {
2025-12-11 19:15:06 +01:00
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
2025-12-07 13:57:52 +01:00
}
void _activateCurrentItem() {
if (_focusedIndex == widget.hub.items.length && widget.hub.more) {
_navigateToHubDetail(context);
return;
}
2025-12-07 13:57:52 +01:00
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
_navigateToItem(item);
}
void _showContextMenuForCurrentItem() {
// No context menu for the "View All" card
if (_focusedIndex >= widget.hub.items.length) return;
2025-12-07 22:51:55 +01:00
_mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu();
2025-12-07 13:57:52 +01:00
}
Future<void> _navigateToItem(dynamic item) async {
2026-01-01 22:06:36 +01:00
await navigateToMediaItem(context, item, onRefresh: widget.onRefresh, playDirectly: widget.isInContinueWatching);
2025-12-07 13:57:52 +01:00
}
2025-12-05 23:29:37 +01:00
void _navigateToHubDetail(BuildContext context) {
2025-12-15 02:29:03 +01:00
Navigator.push(context, MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)));
2025-11-26 13:51:25 +01:00
}
2025-11-16 20:53:59 +01:00
@override
Widget build(BuildContext context) {
2025-12-07 13:57:52 +01:00
final hasFocus = _hubFocusNode.hasFocus;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
2025-12-07 13:57:52 +01:00
2025-12-05 23:29:37 +01:00
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
2025-12-07 13:57:52 +01:00
// Hub header (NOT focusable - titles should not be focusable)
2025-12-05 23:29:37 +01:00
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
2025-12-07 13:57:52 +01:00
child: ExcludeFocus(
child: InkWell(
2025-12-15 02:29:03 +01:00
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null,
2025-12-14 21:03:11 +01:00
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
2025-12-07 13:57:52 +01:00
child: Padding(
2025-12-07 15:00:28 +01:00
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
2025-12-07 13:57:52 +01:00
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
2025-12-12 15:57:22 +01:00
AppIcon(widget.icon, fill: 1),
2025-12-07 13:57:52 +01:00
const SizedBox(width: 8),
2026-01-14 14:17:56 +01:00
Flexible(
2025-12-07 13:57:52 +01:00
child: Text(
widget.hub.title,
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
2025-11-26 13:51:25 +01:00
),
2026-01-14 14:17:56 +01:00
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) ...[
2025-12-07 13:57:52 +01:00
const SizedBox(width: 4),
2025-12-15 02:29:03 +01:00
const AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20),
2025-12-07 13:57:52 +01:00
],
2025-12-05 23:29:37 +01:00
],
2025-12-07 13:57:52 +01:00
),
2025-11-16 20:53:59 +01:00
),
),
),
2025-12-05 23:29:37 +01:00
),
2025-11-16 20:53:59 +01:00
2025-12-07 13:57:52 +01:00
// Hub items with locked focus control
if (widget.hub.items.isNotEmpty)
Focus(
focusNode: _hubFocusNode,
onKeyEvent: _handleKeyEvent,
2026-02-24 02:38:06 +01:00
child: Builder(
builder: (context) {
// Use MediaQuery instead of LayoutBuilder to save 1 element level.
// HubSection is always full-width inside CustomScrollView.
final screenWidth = MediaQuery.of(context).size.width;
final settings = context.watch<SettingsProvider>();
final densityScale = switch (settings.libraryDensity) {
LibraryDensity.compact => 0.8,
LibraryDensity.normal => 1.0,
LibraryDensity.comfortable => 1.15,
};
2026-02-15 03:14:04 +01:00
double baseWidth;
if (ScreenBreakpoints.isLargeDesktop(screenWidth)) {
baseWidth = 220.0;
} else if (ScreenBreakpoints.isDesktop(screenWidth)) {
baseWidth = 200.0;
} else if (ScreenBreakpoints.isWideTablet(screenWidth)) {
baseWidth = 190.0;
} else {
baseWidth = 160.0;
}
final baseCardWidth = baseWidth * densityScale;
2025-12-07 13:57:52 +01:00
2026-01-08 14:42:49 +01:00
// Get episode poster mode setting
final episodePosterMode = settings.episodePosterMode;
2026-01-08 14:42:49 +01:00
// Determine hub content type for layout decisions
2026-01-14 14:17:56 +01:00
final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
2026-01-08 14:42:49 +01:00
// Mixed hub = has both episodes AND non-episodes (like Continue Watching)
final isMixedHub = hasEpisodes && hasNonEpisodes;
// Episode-only = all items are episodes with thumbnails
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
2026-01-14 14:17:56 +01:00
final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
2026-01-08 14:42:49 +01:00
// Card dimensions based on hub type
const wideCardMultiplier = 1.5;
final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth;
final posterWidth = cardWidth - 16; // 8px padding on each side
final posterHeight = useWideLayout
2026-01-14 14:17:56 +01:00
? posterWidth *
(9 / 16) // 16:9 for wide layout
: posterWidth * 1.5; // 2:3 for poster layout
2025-12-07 13:57:52 +01:00
final containerHeight = posterHeight + 66;
2026-01-08 14:42:49 +01:00
_itemExtent = cardWidth + 4;
2025-12-07 13:57:52 +01:00
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
2025-12-15 02:29:03 +01:00
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
2025-12-07 13:57:52 +01:00
itemBuilder: (context, index) {
2025-12-15 02:29:03 +01:00
final isItemFocused = hasFocus && index == _focusedIndex;
2025-12-07 13:57:52 +01:00
// "View All" card at end
if (index == widget.hub.items.length) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
2026-02-24 02:38:06 +01:00
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isItemFocused,
onTap: () {
_onItemTapped(index);
_navigateToHubDetail(context);
},
child: SizedBox(
width: 80,
height: containerHeight - 10,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.arrow_forward_rounded,
size: 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),
),
),
],
),
),
),
),
);
}
final item = widget.hub.items[index];
2025-12-07 13:57:52 +01:00
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
2026-02-24 02:38:06 +01:00
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
2025-12-07 13:57:52 +01:00
isFocused: isItemFocused,
onTap: () => _onItemTapped(index),
2025-12-15 02:29:03 +01:00
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
2025-12-07 13:57:52 +01:00
child: MediaCard(
2025-12-07 22:51:55 +01:00
key: _getMediaCardKey(index),
2025-12-07 13:57:52 +01:00
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: widget.onRefresh,
2025-12-15 02:29:03 +01:00
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
2025-12-07 13:57:52 +01:00
forceGridMode: true,
isInContinueWatching: widget.isInContinueWatching,
2026-01-08 14:42:49 +01:00
mixedHubContext: isMixedHub,
2025-12-07 13:57:52 +01:00
),
),
);
},
2025-11-26 13:51:25 +01:00
),
2025-11-16 20:53:59 +01:00
),
2025-12-07 13:57:52 +01:00
);
},
),
2025-12-05 23:29:37 +01:00
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
t.messages.noItemsAvailable,
2025-12-15 02:29:03 +01:00
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
2025-11-16 20:53:59 +01:00
),
2025-12-05 23:29:37 +01:00
),
],
2025-11-16 20:53:59 +01:00
);
}
2025-12-07 13:57:52 +01:00
/// Called when an item is tapped (mouse/touch)
void _onItemTapped(int index) {
2026-02-15 03:14:04 +01:00
setState(() {
_focusedIndex = index;
});
2025-12-07 13:57:52 +01:00
HubFocusMemory.setForHub(widget.hub.hubKey, index);
_hubFocusNode.requestFocus();
}
}