diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 971fa505..e1eee8e7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,6 +1,8 @@ PODS: - connectivity_plus (0.0.1): - Flutter + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) - os_media_controls (0.0.1): - Flutter @@ -20,6 +22,7 @@ PODS: DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -31,6 +34,8 @@ DEPENDENCIES: EXTERNAL SOURCES: connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter os_media_controls: @@ -48,6 +53,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart new file mode 100644 index 00000000..d82b5313 --- /dev/null +++ b/lib/focus/dpad_navigator.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A widget that provides global D-pad/keyboard navigation handling. +/// +/// Wrap this around your MaterialApp or main content to enable: +/// - Back navigation with Escape/Back/GamepadB +/// - Focus traversal with arrow keys (handled by Flutter's focus system) +/// +/// Example: +/// ```dart +/// DpadNavigator( +/// child: MaterialApp(...), +/// ) +/// ``` +class DpadNavigator extends StatelessWidget { + /// The child widget (typically MaterialApp). + final Widget child; + + /// Whether D-pad navigation is enabled. + final bool enabled; + + /// Called when back navigation is triggered. + /// If null, uses Navigator.maybePop(). + final VoidCallback? onBack; + + const DpadNavigator({ + super.key, + required this.child, + this.enabled = true, + this.onBack, + }); + + @override + Widget build(BuildContext context) { + if (!enabled) { + return child; + } + + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: child, + ); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Handle back navigation + if (_isBackKey(event.logicalKey)) { + if (onBack != null) { + onBack!(); + return KeyEventResult.handled; + } + + // Try to pop navigation if we have a context + final context = node.context; + if (context != null) { + final navigator = Navigator.maybeOf(context); + if (navigator != null && navigator.canPop()) { + navigator.pop(); + return KeyEventResult.handled; + } + } + } + + // Arrow keys and D-pad directions are handled by Flutter's focus system + // We don't need to intercept them here + + return KeyEventResult.ignored; + } + + bool _isBackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.browserBack || + key == LogicalKeyboardKey.gameButtonB; + } +} + +/// Extension methods for checking D-pad related keys. +extension DpadKeyExtension on LogicalKeyboardKey { + /// Whether this key is a D-pad directional key. + bool get isDpadDirection { + return this == LogicalKeyboardKey.arrowUp || + this == LogicalKeyboardKey.arrowDown || + this == LogicalKeyboardKey.arrowLeft || + this == LogicalKeyboardKey.arrowRight; + } + + /// Whether this key is a select/activate key. + bool get isSelectKey { + return this == LogicalKeyboardKey.select || + this == LogicalKeyboardKey.enter || + this == LogicalKeyboardKey.numpadEnter || + this == LogicalKeyboardKey.gameButtonA; + } + + /// Whether this key is a back/cancel key. + bool get isBackKey { + return this == LogicalKeyboardKey.escape || + this == LogicalKeyboardKey.goBack || + this == LogicalKeyboardKey.browserBack || + this == LogicalKeyboardKey.gameButtonB; + } + + /// Whether this key is a context menu key. + bool get isContextMenuKey { + return this == LogicalKeyboardKey.contextMenu || + this == LogicalKeyboardKey.gameButtonX; + } + + /// Whether this key moves focus left. + bool get isLeftKey { + return this == LogicalKeyboardKey.arrowLeft; + } + + /// Whether this key moves focus right. + bool get isRightKey { + return this == LogicalKeyboardKey.arrowRight; + } + + /// Whether this key moves focus up. + bool get isUpKey { + return this == LogicalKeyboardKey.arrowUp; + } + + /// Whether this key moves focus down. + bool get isDownKey { + return this == LogicalKeyboardKey.arrowDown; + } +} diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart new file mode 100644 index 00000000..53c9c8c9 --- /dev/null +++ b/lib/focus/focus_theme.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../theme/mono_tokens.dart'; + +/// Focus styling constants for D-pad navigation. +class FocusTheme { + FocusTheme._(); + + /// Scale factor when an item is focused. + static const double focusScale = 1.02; + + /// Border width for the focus indicator. + static const double focusBorderWidth = 2.5; + + /// Default border radius (matches MonoTokens.radiusSm). + static const double defaultBorderRadius = 8.0; + + /// Get the focus border color from the theme. + static Color getFocusBorderColor(BuildContext context) { + return Theme.of(context).colorScheme.primary; + } + + /// Get the animation duration from MonoTokens. + static Duration getAnimationDuration(BuildContext context) { + return Theme.of(context).extension()?.fast ?? + const Duration(milliseconds: 150); + } + + /// Build the focus border decoration. + static BoxDecoration focusDecoration( + BuildContext context, { + required bool isFocused, + double borderRadius = defaultBorderRadius, + }) { + final focusColor = getFocusBorderColor(context); + + return BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: isFocused ? focusColor : Colors.transparent, + width: focusBorderWidth, + ), + ); + } +} diff --git a/lib/focus/focusable_list_item.dart b/lib/focus/focusable_list_item.dart new file mode 100644 index 00000000..29647642 --- /dev/null +++ b/lib/focus/focusable_list_item.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'focusable_wrapper.dart'; +import 'focus_theme.dart'; + +/// A focusable list item for settings screens and menus. +/// +/// Wraps a ListTile with focus support for D-pad navigation. +/// +/// Example: +/// ```dart +/// FocusableListItem( +/// leading: Icon(Icons.settings), +/// title: Text('Setting Name'), +/// subtitle: Text('Description'), +/// onTap: () => openSetting(), +/// ) +/// ``` +class FocusableListItem extends StatelessWidget { + /// Leading widget (typically an icon). + final Widget? leading; + + /// Title widget. + final Widget title; + + /// Subtitle widget. + final Widget? subtitle; + + /// Trailing widget. + final Widget? trailing; + + /// Called when the item is tapped or selected. + final VoidCallback? onTap; + + /// Optional external FocusNode. + final FocusNode? focusNode; + + /// Whether this item should autofocus. + final bool autofocus; + + /// Whether the item is enabled. + final bool enabled; + + /// Border radius for the focus indicator. + final double borderRadius; + + /// Content padding. + final EdgeInsetsGeometry? contentPadding; + + const FocusableListItem({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.focusNode, + this.autofocus = false, + this.enabled = true, + this.borderRadius = 12.0, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + focusNode: focusNode, + autofocus: autofocus, + onSelect: enabled ? onTap : null, + borderRadius: borderRadius, + canRequestFocus: enabled, + child: ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: onTap, + enabled: enabled, + contentPadding: contentPadding, + ), + ); + } +} + +/// A focusable switch list item for boolean settings. +/// +/// Example: +/// ```dart +/// FocusableSwitchListItem( +/// secondary: Icon(Icons.dark_mode), +/// title: Text('Dark Mode'), +/// value: isDarkMode, +/// onChanged: (value) => setDarkMode(value), +/// ) +/// ``` +class FocusableSwitchListItem extends StatelessWidget { + /// Secondary widget (typically an icon), displayed before the title. + final Widget? secondary; + + /// Title widget. + final Widget title; + + /// Subtitle widget. + final Widget? subtitle; + + /// The current value of the switch. + final bool value; + + /// Called when the switch value changes. + final ValueChanged? onChanged; + + /// Optional external FocusNode. + final FocusNode? focusNode; + + /// Whether this item should autofocus. + final bool autofocus; + + /// Border radius for the focus indicator. + final double borderRadius; + + /// Content padding. + final EdgeInsetsGeometry? contentPadding; + + const FocusableSwitchListItem({ + super.key, + this.secondary, + required this.title, + this.subtitle, + required this.value, + this.onChanged, + this.focusNode, + this.autofocus = false, + this.borderRadius = 12.0, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + focusNode: focusNode, + autofocus: autofocus, + onSelect: onChanged != null ? () => onChanged!(!value) : null, + borderRadius: borderRadius, + canRequestFocus: onChanged != null, + child: SwitchListTile( + secondary: secondary, + title: title, + subtitle: subtitle, + value: value, + onChanged: onChanged, + contentPadding: contentPadding, + ), + ); + } +} + +/// A focusable checkbox list item. +/// +/// Example: +/// ```dart +/// FocusableCheckboxListItem( +/// title: Text('Enable Feature'), +/// value: isEnabled, +/// onChanged: (value) => setEnabled(value ?? false), +/// ) +/// ``` +class FocusableCheckboxListItem extends StatelessWidget { + /// Secondary widget (typically an icon). + final Widget? secondary; + + /// Title widget. + final Widget title; + + /// Subtitle widget. + final Widget? subtitle; + + /// The current value of the checkbox. + final bool? value; + + /// Called when the checkbox value changes. + final ValueChanged? onChanged; + + /// Optional external FocusNode. + final FocusNode? focusNode; + + /// Whether this item should autofocus. + final bool autofocus; + + /// Border radius for the focus indicator. + final double borderRadius; + + /// Content padding. + final EdgeInsetsGeometry? contentPadding; + + /// Whether the checkbox is tristate. + final bool tristate; + + const FocusableCheckboxListItem({ + super.key, + this.secondary, + required this.title, + this.subtitle, + required this.value, + this.onChanged, + this.focusNode, + this.autofocus = false, + this.borderRadius = 12.0, + this.contentPadding, + this.tristate = false, + }); + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + focusNode: focusNode, + autofocus: autofocus, + onSelect: onChanged != null + ? () { + if (tristate) { + // Cycle through: false -> true -> null -> false + if (value == null) { + onChanged!(false); + } else if (value!) { + onChanged!(null); + } else { + onChanged!(true); + } + } else { + onChanged!(!(value ?? false)); + } + } + : null, + borderRadius: borderRadius, + canRequestFocus: onChanged != null, + child: CheckboxListTile( + secondary: secondary, + title: title, + subtitle: subtitle, + value: value, + onChanged: onChanged, + contentPadding: contentPadding, + tristate: tristate, + ), + ); + } +} + +/// A focusable radio list item. +/// +/// Example: +/// ```dart +/// FocusableRadioListItem( +/// title: Text('Option A'), +/// value: 'a', +/// groupValue: selectedValue, +/// onChanged: (value) => setSelected(value), +/// ) +/// ``` +class FocusableRadioListItem extends StatelessWidget { + /// Secondary widget (typically an icon). + final Widget? secondary; + + /// Title widget. + final Widget title; + + /// Subtitle widget. + final Widget? subtitle; + + /// The value represented by this radio button. + final T value; + + /// The currently selected value for the group. + final T? groupValue; + + /// Called when this radio button is selected. + final ValueChanged? onChanged; + + /// Optional external FocusNode. + final FocusNode? focusNode; + + /// Whether this item should autofocus. + final bool autofocus; + + /// Border radius for the focus indicator. + final double borderRadius; + + /// Content padding. + final EdgeInsetsGeometry? contentPadding; + + const FocusableRadioListItem({ + super.key, + this.secondary, + required this.title, + this.subtitle, + required this.value, + required this.groupValue, + this.onChanged, + this.focusNode, + this.autofocus = false, + this.borderRadius = 12.0, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + focusNode: focusNode, + autofocus: autofocus, + onSelect: onChanged != null ? () => onChanged!(value) : null, + borderRadius: borderRadius, + canRequestFocus: onChanged != null, + child: RadioListTile( + secondary: secondary, + title: title, + subtitle: subtitle, + value: value, + groupValue: groupValue, + onChanged: onChanged, + contentPadding: contentPadding, + ), + ); + } +} diff --git a/lib/focus/focusable_scroll_section.dart b/lib/focus/focusable_scroll_section.dart new file mode 100644 index 00000000..b72b65c7 --- /dev/null +++ b/lib/focus/focusable_scroll_section.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; + +/// A horizontal scrollable section with focus memory and auto-scroll support. +/// +/// Use this for rows of focusable items (like HubSection) to: +/// - Remember the last focused item when navigating away +/// - Auto-scroll to keep the focused item visible +/// - Provide smooth focus traversal within the section +/// +/// Example: +/// ```dart +/// FocusableScrollSection( +/// sectionId: 'recently_added', +/// itemCount: items.length, +/// itemBuilder: (context, index, focusNode) { +/// return FocusableWrapper( +/// focusNode: focusNode, +/// onSelect: () => navigateTo(items[index]), +/// child: MediaCard(item: items[index]), +/// ); +/// }, +/// ) +/// ``` +class FocusableScrollSection extends StatefulWidget { + /// Unique identifier for focus memory persistence. + final String sectionId; + + /// Number of items in the section. + final int itemCount; + + /// Builder for each focusable item. + /// The [focusNode] should be passed to a FocusableWrapper or Focus widget. + final Widget Function(BuildContext context, int index, FocusNode focusNode) + itemBuilder; + + /// Optional scroll controller for external control. + final ScrollController? scrollController; + + /// Whether to remember the last focused item. + final bool rememberFocus; + + /// Padding around the scrollable area. + final EdgeInsets padding; + + /// Spacing between items. + final double itemSpacing; + + /// Called when the section gains focus. + final VoidCallback? onSectionFocused; + + /// Called when the section loses focus. + final VoidCallback? onSectionBlurred; + + const FocusableScrollSection({ + super.key, + required this.sectionId, + required this.itemCount, + required this.itemBuilder, + this.scrollController, + this.rememberFocus = true, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + this.itemSpacing = 4.0, + this.onSectionFocused, + this.onSectionBlurred, + }); + + @override + State createState() => _FocusableScrollSectionState(); +} + +class _FocusableScrollSectionState extends State { + late ScrollController _scrollController; + bool _ownsController = false; + late FocusScopeNode _focusScopeNode; + + final List _itemFocusNodes = []; + int _lastFocusedIndex = 0; + bool _hasFocus = false; + + // Global focus memory (shared across sections) + static final Map _focusMemory = {}; + + @override + void initState() { + super.initState(); + _initScrollController(); + _initFocusScopeNode(); + _createFocusNodes(); + _restoreFocusMemory(); + } + + void _initScrollController() { + if (widget.scrollController != null) { + _scrollController = widget.scrollController!; + _ownsController = false; + } else { + _scrollController = ScrollController(); + _ownsController = true; + } + } + + void _initFocusScopeNode() { + _focusScopeNode = FocusScopeNode( + debugLabel: 'FocusableScrollSection_${widget.sectionId}', + ); + } + + void _createFocusNodes() { + _disposeFocusNodes(); + for (int i = 0; i < widget.itemCount; i++) { + final node = FocusNode( + debugLabel: '${widget.sectionId}_item_$i', + ); + node.addListener(() => _handleItemFocusChange(i, node.hasFocus)); + _itemFocusNodes.add(node); + } + } + + void _disposeFocusNodes() { + for (final node in _itemFocusNodes) { + node.dispose(); + } + _itemFocusNodes.clear(); + } + + void _restoreFocusMemory() { + if (widget.rememberFocus) { + _lastFocusedIndex = _focusMemory[widget.sectionId] ?? 0; + _lastFocusedIndex = _lastFocusedIndex.clamp(0, widget.itemCount - 1); + } + } + + void _saveFocusMemory() { + if (widget.rememberFocus) { + _focusMemory[widget.sectionId] = _lastFocusedIndex; + } + } + + @override + void didUpdateWidget(FocusableScrollSection oldWidget) { + super.didUpdateWidget(oldWidget); + + // Handle scroll controller changes + if (widget.scrollController != oldWidget.scrollController) { + if (_ownsController) { + _scrollController.dispose(); + } + _initScrollController(); + } + + // Handle item count changes + if (widget.itemCount != oldWidget.itemCount) { + _createFocusNodes(); + _lastFocusedIndex = _lastFocusedIndex.clamp(0, widget.itemCount - 1); + } + } + + @override + void dispose() { + _saveFocusMemory(); + _disposeFocusNodes(); + _focusScopeNode.dispose(); + if (_ownsController) { + _scrollController.dispose(); + } + super.dispose(); + } + + void _handleItemFocusChange(int index, bool hasFocus) { + if (hasFocus) { + _lastFocusedIndex = index; + _saveFocusMemory(); + _scrollItemIntoView(index); + + if (!_hasFocus) { + _hasFocus = true; + widget.onSectionFocused?.call(); + } + } else { + // Check if section lost focus entirely + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final stillHasFocus = + _itemFocusNodes.any((node) => node.hasFocus); + if (_hasFocus && !stillHasFocus) { + _hasFocus = false; + widget.onSectionBlurred?.call(); + } + }); + } + } + + void _scrollItemIntoView(int index) { + if (!_scrollController.hasClients) return; + + // We'll use ensureVisible which is more reliable + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || index >= _itemFocusNodes.length) return; + + final focusNode = _itemFocusNodes[index]; + final context = focusNode.context; + if (context == null) return; + + Scrollable.ensureVisible( + context, + alignment: 0.5, // Center the item + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }); + } + + /// Request focus to the last focused item (or first item). + void requestFocus() { + if (_itemFocusNodes.isEmpty) return; + + final index = _lastFocusedIndex.clamp(0, _itemFocusNodes.length - 1); + _itemFocusNodes[index].requestFocus(); + } + + /// Request focus to a specific item by index. + void requestFocusAt(int index) { + if (index < 0 || index >= _itemFocusNodes.length) return; + _itemFocusNodes[index].requestFocus(); + } + + @override + Widget build(BuildContext context) { + if (widget.itemCount == 0) { + return const SizedBox.shrink(); + } + + return FocusScope( + node: _focusScopeNode, + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: widget.padding, + itemCount: widget.itemCount, + itemBuilder: (context, index) { + final focusNode = _itemFocusNodes[index]; + return Padding( + padding: EdgeInsets.symmetric(horizontal: widget.itemSpacing / 2), + child: widget.itemBuilder(context, index, focusNode), + ); + }, + ), + ), + ); + } +} + +/// Static methods for managing focus memory across sections. +class FocusableScrollSectionMemory { + FocusableScrollSectionMemory._(); + + /// Clear focus memory for a specific section. + static void clear(String sectionId) { + _FocusableScrollSectionState._focusMemory.remove(sectionId); + } + + /// Clear all focus memory. + static void clearAll() { + _FocusableScrollSectionState._focusMemory.clear(); + } + + /// Get the last focused index for a section. + static int? get(String sectionId) { + return _FocusableScrollSectionState._focusMemory[sectionId]; + } + + /// Set the focus memory for a section. + static void set(String sectionId, int index) { + _FocusableScrollSectionState._focusMemory[sectionId] = index; + } +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart new file mode 100644 index 00000000..8a892da3 --- /dev/null +++ b/lib/focus/focusable_wrapper.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'focus_theme.dart'; +import 'input_mode_tracker.dart'; + +/// A wrapper widget that makes its child focusable with D-pad navigation support. +/// +/// Provides: +/// - Visual focus indicator (border + scale animation) +/// - Keyboard/D-pad event handling (Enter/Select to activate) +/// - Optional auto-scroll to keep focused item visible +class FocusableWrapper extends StatefulWidget { + /// The child widget to wrap. + final Widget child; + + /// Called when the item is selected (Enter/Select/GamepadA). + final VoidCallback? onSelect; + + /// Called when long press is triggered (context menu key). + final VoidCallback? onLongPress; + + /// Called when focus changes. + final ValueChanged? onFocusChange; + + /// Whether this widget should request focus when first built. + final bool autofocus; + + /// Optional external FocusNode for programmatic focus control. + final FocusNode? focusNode; + + /// Border radius for the focus indicator. + final double borderRadius; + + /// Whether to scroll the widget into view when focused. + final bool autoScroll; + + /// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end). + final double scrollAlignment; + + /// Optional semantic label for accessibility. + final String? semanticLabel; + + /// Whether the wrapper can receive focus. + final bool canRequestFocus; + + /// Custom key event handler. Return KeyEventResult.handled to consume the event. + /// This is called before the default key handling. + final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; + + const FocusableWrapper({ + super.key, + required this.child, + this.onSelect, + this.onLongPress, + this.onFocusChange, + this.autofocus = false, + this.focusNode, + this.borderRadius = FocusTheme.defaultBorderRadius, + this.autoScroll = true, + this.scrollAlignment = 0.5, + this.semanticLabel, + this.canRequestFocus = true, + this.onKeyEvent, + }); + + @override + State createState() => _FocusableWrapperState(); +} + +class _FocusableWrapperState extends State + with SingleTickerProviderStateMixin { + late FocusNode _focusNode; + bool _ownsNode = false; + bool _isFocused = false; + + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _initFocusNode(); + _initAnimations(); + } + + void _initFocusNode() { + if (widget.focusNode != null) { + _focusNode = widget.focusNode!; + _ownsNode = false; + } else { + _focusNode = FocusNode( + debugLabel: widget.semanticLabel ?? 'FocusableWrapper', + canRequestFocus: widget.canRequestFocus, + ); + _ownsNode = true; + } + } + + void _initAnimations() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: FocusTheme.focusScale, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + } + + @override + void didUpdateWidget(FocusableWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + + // Handle focusNode changes + if (widget.focusNode != oldWidget.focusNode) { + if (_ownsNode) { + _focusNode.dispose(); + } + _initFocusNode(); + } + + // Update canRequestFocus + if (widget.canRequestFocus != oldWidget.canRequestFocus) { + _focusNode.canRequestFocus = widget.canRequestFocus; + } + } + + @override + void dispose() { + _animationController.dispose(); + if (_ownsNode) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _handleFocusChange(bool hasFocus) { + if (_isFocused != hasFocus) { + setState(() { + _isFocused = hasFocus; + }); + + // Animate scale + if (hasFocus) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + + // Auto-scroll into view + if (hasFocus && widget.autoScroll) { + _scrollIntoView(); + } + + // Notify listener + widget.onFocusChange?.call(hasFocus); + } + } + + void _scrollIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final renderObject = context.findRenderObject(); + if (renderObject == null) return; + + Scrollable.ensureVisible( + context, + alignment: widget.scrollAlignment, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Call custom key handler first + if (widget.onKeyEvent != null) { + final result = widget.onKeyEvent!(node, event); + if (result == KeyEventResult.handled) { + return result; + } + } + + // Handle Select/Enter for activation + if (_isSelectKey(event.logicalKey)) { + widget.onSelect?.call(); + return KeyEventResult.handled; + } + + // Handle context menu key + if (_isContextMenuKey(event.logicalKey)) { + widget.onLongPress?.call(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + bool _isSelectKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.gameButtonA; + } + + bool _isContextMenuKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.contextMenu || + key == LogicalKeyboardKey.gameButtonX; + } + + @override + Widget build(BuildContext context) { + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + + // Update animation duration if theme changes + if (_animationController.duration != duration) { + _animationController.duration = duration; + } + + Widget result = Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + onKeyEvent: _handleKeyEvent, + child: AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: showFocus ? _scaleAnimation.value : 1.0, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: widget.borderRadius, + ), + child: widget.child, + ), + ); + }, + ), + ); + + // Add semantics if label provided + if (widget.semanticLabel != null) { + result = Semantics( + label: widget.semanticLabel, + button: widget.onSelect != null, + child: result, + ); + } + + return result; + } +} diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart new file mode 100644 index 00000000..363b9381 --- /dev/null +++ b/lib/focus/input_mode_tracker.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../services/tv_detection_service.dart'; + +/// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch). +/// +/// Focus effects should only be shown during keyboard navigation. +enum InputMode { keyboard, pointer } + +/// Provides input mode tracking to descendant widgets. +/// +/// Wrap your app with this widget to enable input mode detection: +/// ```dart +/// InputModeTracker( +/// child: MaterialApp(...), +/// ) +/// ``` +/// +/// Then check the mode in focusable widgets: +/// ```dart +/// final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); +/// ``` +class InputModeTracker extends StatefulWidget { + final Widget child; + + const InputModeTracker({super.key, required this.child}); + + /// Get the current input mode. + static InputMode of(BuildContext context) { + final provider = + context.dependOnInheritedWidgetOfExactType<_InputModeProvider>(); + return provider?.mode ?? InputMode.pointer; + } + + /// Convenience method to check if we're in keyboard mode. + static bool isKeyboardMode(BuildContext context) { + return of(context) == InputMode.keyboard; + } + + @override + State createState() => _InputModeTrackerState(); +} + +class _InputModeTrackerState extends State { + // Default to keyboard mode on Android TV, pointer mode elsewhere + InputMode _mode = + TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer; + + @override + void initState() { + super.initState(); + // Listen to hardware keyboard events globally + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + super.dispose(); + } + + bool _handleKeyEvent(KeyEvent event) { + // Only switch to keyboard mode on key down (not repeats or releases) + if (event is KeyDownEvent) { + _setMode(InputMode.keyboard); + } + // Return false to let the event continue propagating + return false; + } + + void _setMode(InputMode mode) { + if (_mode != mode) { + setState(() => _mode = mode); + } + } + + @override + Widget build(BuildContext context) { + return Listener( + // Switch to pointer mode on mouse activity + onPointerDown: (_) => _setMode(InputMode.pointer), + onPointerHover: (_) => _setMode(InputMode.pointer), + behavior: HitTestBehavior.translucent, + child: _InputModeProvider( + mode: _mode, + child: widget.child, + ), + ); + } +} + +/// InheritedWidget that provides the current input mode. +class _InputModeProvider extends InheritedWidget { + final InputMode mode; + + const _InputModeProvider({ + required this.mode, + required super.child, + }); + + @override + bool updateShouldNotify(_InputModeProvider oldWidget) { + return mode != oldWidget.mode; + } +} diff --git a/lib/focus/locked_hub_controller.dart b/lib/focus/locked_hub_controller.dart new file mode 100644 index 00000000..815e4307 --- /dev/null +++ b/lib/focus/locked_hub_controller.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +/// Controller for locked hub navigation. +/// Manages visual focus index separately from Flutter's focus system. +class LockedHubController extends ChangeNotifier { + LockedHubController({ + required this.itemExtent, + this.leadingPadding = 12.0, + ScrollController? scrollController, + }) : scrollController = scrollController ?? ScrollController(); + + /// Width of each item (including padding/margin) + final double itemExtent; + + /// Leading padding before first item + final double leadingPadding; + + /// Scroll controller for the list + final ScrollController scrollController; + + int _focusedIndex = 0; + int _itemCount = 0; + bool _hasFocus = false; + + /// Current visual focus index + int get focusedIndex => _focusedIndex; + + /// Number of items in the hub + int get itemCount => _itemCount; + + /// Whether the hub currently has focus + bool get hasFocus => _hasFocus; + + /// Update the item count (call when hub items change) + void updateItemCount(int count) { + if (_itemCount != count) { + _itemCount = count; + // Clamp focus index if it's now out of bounds + if (_focusedIndex >= count && count > 0) { + _focusedIndex = count - 1; + notifyListeners(); + } + } + } + + /// Set hub focus state + void setHasFocus(bool value) { + if (_hasFocus != value) { + _hasFocus = value; + notifyListeners(); + } + } + + /// Focus a specific index, scrolling to it + void focusIndex(int index, {bool animate = true}) { + if (_itemCount == 0) return; + + final clamped = index.clamp(0, _itemCount - 1); + if (_focusedIndex != clamped) { + _focusedIndex = clamped; + notifyListeners(); + } + _scrollToIndex(clamped, animate: animate); + } + + /// Move focus left, return false if at boundary + bool moveLeft() { + if (_focusedIndex > 0) { + focusIndex(_focusedIndex - 1); + return true; + } + return false; + } + + /// Move focus right, return false if at boundary + bool moveRight() { + if (_focusedIndex < _itemCount - 1) { + focusIndex(_focusedIndex + 1); + return true; + } + return false; + } + + /// Scroll to center the item at the given index + void _scrollToIndex(int index, {bool animate = true}) { + if (!scrollController.hasClients) 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); + } + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } +} + +/// Manages focus memory for hub navigation. +/// +/// Tracks two things: +/// 1. Per-hub memory: Each hub remembers which item was last focused +/// 2. Global column hint: When entering a hub that hasn't been visited, +/// we use the column position from the last focused hub as a hint +class HubFocusMemory { + static final Map _perHubMemory = {}; + static int _lastColumnHint = 0; + + /// Remember the focused index for a specific hub + static void setForHub(String hubKey, int index) { + _perHubMemory[hubKey] = index; + _lastColumnHint = index; + } + + /// Get the remembered index for a hub, or fall back to column hint + static int getForHub(String hubKey, int itemCount) { + if (itemCount <= 0) return 0; + + // If this hub has memory, use it + if (_perHubMemory.containsKey(hubKey)) { + return _perHubMemory[hubKey]!.clamp(0, itemCount - 1); + } + + // Otherwise use the last column hint (clamped to this hub's item count) + return _lastColumnHint.clamp(0, itemCount - 1); + } + + /// Clear all memory (e.g., when leaving a screen) + static void clear() { + _perHubMemory.clear(); + _lastColumnHint = 0; + } +} diff --git a/lib/focus/locked_hub_item.dart b/lib/focus/locked_hub_item.dart new file mode 100644 index 00000000..bfaa2d27 --- /dev/null +++ b/lib/focus/locked_hub_item.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'focus_theme.dart'; +import 'input_mode_tracker.dart'; + +/// A hub item that renders focus visuals based on passed-in state. +/// Does NOT use FocusNode - relies on parent-managed visual focus. +class LockedHubItem extends StatelessWidget { + /// Whether this item is visually focused + final bool isFocused; + + /// The child widget to wrap + final Widget child; + + /// Border radius for the focus indicator + final double borderRadius; + + const LockedHubItem({ + super.key, + required this.isFocused, + required this.child, + this.borderRadius = FocusTheme.defaultBorderRadius, + }); + + @override + Widget build(BuildContext context) { + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = isFocused && InputModeTracker.isKeyboardMode(context); + + return AnimatedScale( + scale: showFocus ? FocusTheme.focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + ), + child: child, + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 48be4064..bf4d66fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'utils/app_logger.dart'; import 'utils/orientation_helper.dart'; import 'utils/language_codes.dart'; import 'i18n/strings.g.dart'; +import 'focus/input_mode_tracker.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -115,14 +116,16 @@ class MainApp extends StatelessWidget { child: Consumer( builder: (context, themeProvider, child) { return TranslationProvider( - child: MaterialApp( - title: t.app.title, - debugShowCheckedModeBanner: false, - theme: themeProvider.lightTheme, - darkTheme: themeProvider.darkTheme, - themeMode: themeProvider.materialThemeMode, - navigatorObservers: [routeObserver], - home: const OrientationAwareSetup(), + child: InputModeTracker( + child: MaterialApp( + title: t.app.title, + debugShowCheckedModeBanner: false, + theme: themeProvider.lightTheme, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.materialThemeMode, + navigatorObservers: [routeObserver], + home: const OrientationAwareSetup(), + ), ), ); }, diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 0b90d477..8b0162c9 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../widgets/media_card.dart'; @@ -112,9 +114,20 @@ class _CollectionDetailScreenState } } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar( @@ -155,6 +168,7 @@ class _CollectionDetailScreenState ), ], ), + ), ); } } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9f7e405b..434820a1 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../services/plex_client.dart'; @@ -23,6 +24,7 @@ import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; +import '../focus/dpad_navigator.dart'; import 'auth_screen.dart'; class DiscoverScreen extends StatefulWidget { @@ -64,6 +66,18 @@ class _DiscoverScreenState extends State late AnimationController _indicatorAnimationController; bool _isAutoScrollPaused = false; + // Hub navigation keys + GlobalKey? _continueWatchingHubKey; + final List> _hubKeys = []; + + // Hero and app bar focus + late FocusNode _heroFocusNode; + late FocusNode _refreshButtonFocusNode; + late FocusNode _userButtonFocusNode; + bool _isHeroFocused = false; + bool _isRefreshFocused = false; + bool _isUserFocused = false; + /// Get the correct PlexClient for an item's server PlexClient _getClientForItem(PlexMetadata? item) { // Items should always have a serverId, but if not, fall back to first available server @@ -83,6 +97,69 @@ class _DiscoverScreenState extends State return context.getClientForServer(serverId); } + /// Update hub keys when hubs list changes + void _updateHubKeys() { + _hubKeys.clear(); + for (int i = 0; i < _hubs.length; i++) { + _hubKeys.add(GlobalKey()); + } + // Create continue watching hub key if needed + if (_onDeck.isNotEmpty) { + _continueWatchingHubKey ??= GlobalKey(); + } + } + + /// Get all hub states (continue watching + other hubs) + List> get _allHubKeys { + final keys = >[]; + if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) { + keys.add(_continueWatchingHubKey!); + } + keys.addAll(_hubKeys); + return keys; + } + + /// Handle vertical navigation between hubs + /// Returns true if the navigation was handled + bool _handleVerticalNavigation(int hubIndex, bool isUp) { + final keys = _allHubKeys; + if (keys.isEmpty) return false; + + // UP from first hub: Navigate to hero section + if (isUp && hubIndex == 0) { + _heroFocusNode.requestFocus(); + // Scroll to top to show hero fully + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + return true; + } + + int targetIndex; + if (isUp) { + targetIndex = hubIndex - 1; + } else { + targetIndex = hubIndex + 1; + } + + // Check if target is valid + if (targetIndex < 0 || targetIndex >= keys.length) { + // At boundary, block navigation (return true to consume the event) + return true; + } + + // Navigate to target hub, clamping to available items + final targetState = keys[targetIndex].currentState; + if (targetState != null) { + targetState.requestFocusFromMemory(); + return true; + } + + return false; + } + @override void initState() { super.initState(); @@ -90,16 +167,177 @@ class _DiscoverScreenState extends State vsync: this, duration: _heroAutoScrollDuration, ); + _heroFocusNode = FocusNode(debugLabel: 'hero_section'); + _refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button'); + _userButtonFocusNode = FocusNode(debugLabel: 'user_button'); + _heroFocusNode.addListener(_onHeroFocusChange); + _refreshButtonFocusNode.addListener(_onRefreshFocusChange); + _userButtonFocusNode.addListener(_onUserFocusChange); _loadContent(); _startAutoScroll(); } + void _onHeroFocusChange() { + if (mounted) { + setState(() { + _isHeroFocused = _heroFocusNode.hasFocus; + }); + } + } + + void _onRefreshFocusChange() { + if (mounted) { + setState(() { + _isRefreshFocused = _refreshButtonFocusNode.hasFocus; + }); + } + } + + void _onUserFocusChange() { + if (mounted) { + setState(() { + _isUserFocused = _userButtonFocusNode.hasFocus; + }); + } + } + + /// Handle key events for the hero section + KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // DOWN: Move to first hub + if (key.isDownKey) { + final keys = _allHubKeys; + if (keys.isNotEmpty) { + keys.first.currentState?.requestFocusFromMemory(); + } + return KeyEventResult.handled; + } + + // UP: Move to app bar (refresh button) + if (key.isUpKey) { + _refreshButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT: Navigate hero carousel to previous + if (key.isLeftKey) { + if (_currentHeroIndex > 0) { + _heroController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + return KeyEventResult.handled; + } + + // RIGHT: Navigate hero carousel to next + if (key.isRightKey) { + if (_currentHeroIndex < _onDeck.length - 1) { + _heroController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + return KeyEventResult.handled; + } + + // SELECT: Play current hero item + if (key.isSelectKey) { + if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) { + navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + /// Handle key events for the refresh button in app bar + KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // DOWN: Return to hero + if (key.isDownKey) { + _heroFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // RIGHT: Move to user button + if (key.isRightKey) { + _userButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT/UP: Block at boundary + if (key.isLeftKey || key.isUpKey) { + return KeyEventResult.handled; + } + + // SELECT: Trigger refresh + if (key.isSelectKey) { + _loadContent(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + /// Handle key events for the user button in app bar + KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // DOWN: Return to hero + if (key.isDownKey) { + _heroFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT: Move to refresh button + if (key.isLeftKey) { + _refreshButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // RIGHT/UP: Block at boundary + if (key.isRightKey || key.isUpKey) { + return KeyEventResult.handled; + } + + // SELECT: Show user menu + if (key.isSelectKey) { + final userProvider = context.read(); + _showUserMenu(context, userProvider); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + @override void dispose() { _autoScrollTimer?.cancel(); _heroController.dispose(); _scrollController.dispose(); _indicatorAnimationController.dispose(); + _heroFocusNode.removeListener(_onHeroFocusChange); + _heroFocusNode.dispose(); + _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); + _refreshButtonFocusNode.dispose(); + _userButtonFocusNode.removeListener(_onUserFocusChange); + _userButtonFocusNode.dispose(); super.dispose(); } @@ -223,6 +461,11 @@ class _DiscoverScreenState extends State // Reset hero index to avoid sync issues _currentHeroIndex = 0; + + // Create continue watching hub key if needed + if (_onDeck.isNotEmpty) { + _continueWatchingHubKey ??= GlobalKey(); + } }); // Sync PageController to first page after OnDeck loads @@ -263,6 +506,7 @@ class _DiscoverScreenState extends State setState(() { _hubs = filteredHubs; _areHubsLoading = false; + _updateHubKeys(); }); appLogger.d('Discover content loaded successfully'); @@ -501,6 +745,60 @@ class _DiscoverScreenState extends State ); } + /// Show user menu programmatically (for D-pad select) + void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { + final RenderBox? button = _userButtonFocusNode.context + ?.findRenderObject() as RenderBox?; + if (button == null) return; + + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero), + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + + showMenu( + context: context, + position: position, + items: [ + if (userProvider.hasMultipleUsers) + PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + Icon(Icons.people), + SizedBox(width: 8), + Text(t.discover.switchProfile), + ], + ), + ), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text(t.discover.logout), + ], + ), + ), + ], + ).then((value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -517,51 +815,75 @@ class _DiscoverScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadContent, + Focus( + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isRefreshFocused + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadContent, + ), + ), ), Consumer( builder: (context, userProvider, child) { - return PopupMenuButton( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget( - user: userProvider.currentUser!, - size: 32, - showIndicators: false, - ) - : const Icon(Icons.account_circle, size: 32), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); - } - }, - itemBuilder: (context) => [ - // Only show Switch Profile if multiple users available - if (userProvider.hasMultipleUsers) - PopupMenuItem( - value: 'switch_profile', - child: Row( - children: [ - Icon(Icons.people), - SizedBox(width: 8), - Text(t.discover.switchProfile), - ], - ), - ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [ - Icon(Icons.logout), - SizedBox(width: 8), - Text(t.discover.logout), - ], - ), + return Focus( + focusNode: _userButtonFocusNode, + onKeyEvent: _handleUserKeyEvent, + child: DecoratedBox( + decoration: BoxDecoration( + color: _isUserFocused + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(20), ), - ], + child: PopupMenuButton( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget( + user: userProvider.currentUser!, + size: 32, + showIndicators: false, + ) + : const Icon(Icons.account_circle, size: 32), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + // Only show Switch Profile if multiple users available + if (userProvider.hasMultipleUsers) + PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + Icon(Icons.people), + SizedBox(width: 8), + Text(t.discover.switchProfile), + ], + ), + ), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text(t.discover.logout), + ], + ), + ), + ], + ), + ), ); }, ), @@ -609,6 +931,7 @@ class _DiscoverScreenState extends State if (_onDeck.isNotEmpty) SliverToBoxAdapter( child: HubSection( + key: _continueWatchingHubKey, hub: PlexHub( hubKey: 'continue_watching', title: t.discover.continueWatching, @@ -622,6 +945,8 @@ class _DiscoverScreenState extends State onRefresh: updateItem, onRemoveFromContinueWatching: _refreshContinueWatching, isInContinueWatching: true, + onVerticalNavigation: (isUp) => + _handleVerticalNavigation(0, isUp), ), ), @@ -629,9 +954,15 @@ class _DiscoverScreenState extends State for (int i = 0; i < _hubs.length; i++) SliverToBoxAdapter( child: HubSection( + key: i < _hubKeys.length ? _hubKeys[i] : null, hub: _hubs[i], icon: _getHubIcon(_hubs[i].title), onRefresh: updateItem, + // Hub index is i + 1 if continue watching exists, otherwise i + onVerticalNavigation: (isUp) => _handleVerticalNavigation( + _onDeck.isNotEmpty ? i + 1 : i, + isUp, + ), ), ), @@ -714,11 +1045,15 @@ class _DiscoverScreenState extends State Widget _buildHeroSection() { return SliverToBoxAdapter( - child: SizedBox( - height: 500, - child: Stack( - children: [ - PageView.builder( + child: Focus( + focusNode: _heroFocusNode, + autofocus: true, + onKeyEvent: _handleHeroKeyEvent, + child: SizedBox( + height: 500, + child: Stack( + children: [ + PageView.builder( controller: _heroController, itemCount: _onDeck.length, onPageChanged: (index) { @@ -837,6 +1172,7 @@ class _DiscoverScreenState extends State ), ], ), + ), ), ); } @@ -1194,62 +1530,76 @@ class _DiscoverScreenState extends State ? heroItem.viewOffset! / heroItem.duration! : 0.0; - return InkWell( - onTap: () { - appLogger.d('Playing: ${heroItem.title}'); - navigateToVideoPlayer(context, metadata: heroItem); - }, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), + // Wrap with AnimatedContainer for focus outline + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: _isHeroFocused + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 3, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.play_arrow, size: 20, color: Colors.black), - const SizedBox(width: 8), - if (hasProgress) ...[ - // Progress bar - Container( - width: 40, - height: 6, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(3), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress, - child: Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(2), + ), + padding: EdgeInsets.all(_isHeroFocused ? 4 : 0), + child: InkWell( + onTap: () { + appLogger.d('Playing: ${heroItem.title}'); + navigateToVideoPlayer(context, metadata: heroItem); + }, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.play_arrow, size: 20, color: Colors.black), + const SizedBox(width: 8), + if (hasProgress) ...[ + // Progress bar + Container( + width: 40, + height: 6, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(3), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress, + child: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(2), + ), ), ), ), - ), - const SizedBox(width: 8), - Text( - t.discover.minutesLeft(minutes: minutesLeft), - style: const TextStyle( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w600, + const SizedBox(width: 8), + Text( + t.discover.minutesLeft(minutes: minutesLeft), + style: const TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - ), - ] else - Text( - t.discover.play, - style: TextStyle( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w600, + ] else + Text( + t.discover.play, + style: TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 5fa997bf..92342d94 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; import '../../services/plex_client.dart'; import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; @@ -240,9 +242,20 @@ class _HubDetailScreenState extends State with Refreshable { _loadMoreItems(); } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar( @@ -309,6 +322,7 @@ class _HubDetailScreenState extends State with Refreshable { ), ], ), + ), ); } } diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 0cdae438..4af66840 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -4,7 +4,7 @@ import '../../models/plex_metadata.dart'; import '../../providers/settings_provider.dart'; import '../../services/settings_service.dart' show ViewMode; import '../../utils/grid_size_calculator.dart'; -import '../../widgets/media_card.dart'; +import '../../widgets/focusable_media_card.dart'; /// A widget that automatically switches between grid and list view /// based on user settings, providing a consistent layout pattern @@ -22,12 +22,20 @@ class AdaptiveMediaGrid extends StatelessWidget { /// Child aspect ratio for grid items (width / height) final double childAspectRatio; + /// Optional focus node for the first item (for programmatic focus) + final FocusNode? firstItemFocusNode; + + /// Callback when back button is pressed (for hierarchical navigation) + final VoidCallback? onBack; + const AdaptiveMediaGrid({ super.key, required this.items, this.onRefresh, this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), this.childAspectRatio = 2 / 3.3, + this.firstItemFocusNode, + this.onBack, }); @override @@ -40,10 +48,12 @@ class AdaptiveMediaGrid extends StatelessWidget { itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - return MediaCard( + return FocusableMediaCard( key: Key(item.ratingKey), item: item, + focusNode: index == 0 ? firstItemFocusNode : null, onListRefresh: onRefresh, + onBack: onBack, ); }, ); @@ -62,10 +72,12 @@ class AdaptiveMediaGrid extends StatelessWidget { itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - return MediaCard( + return FocusableMediaCard( key: Key(item.ratingKey), item: item, + focusNode: index == 0 ? firstItemFocusNode : null, onListRefresh: onRefresh, + onBack: onBack, ); }, ); diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index c697d9f4..b7ff5a25 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; +import '../../focus/dpad_navigator.dart'; +import '../../focus/input_mode_tracker.dart'; import '../../../services/plex_client.dart'; import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; @@ -11,6 +14,8 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focusable_tab_chip.dart'; +import '../main_screen.dart'; import 'context_menu_wrapper.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; @@ -63,6 +68,9 @@ class _LibrariesScreenState extends State bool _isInitialLoad = true; List? _serverOrder; // Cached server order from storage + /// When true, suppress auto-focus in tabs (used when navigating via tab bar) + bool _suppressAutoFocus = false; + Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; @@ -73,9 +81,21 @@ class _LibrariesScreenState extends State int _requestId = 0; static const int _pageSize = 1000; + /// Flag to prevent _onTabChanged from focusing when we're programmatically changing tabs + bool _isRestoringTab = false; + + /// Track which tabs have loaded data (used to trigger focus after tab restore) + final Set _loadedTabs = {}; + /// Key for the library dropdown popup menu button final _libraryDropdownKey = GlobalKey>(); + // Focus nodes for tab chips + final _recommendedTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_recommended'); + final _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse'); + final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections'); + final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists'); + @override void initState() { super.initState(); @@ -85,24 +105,158 @@ class _LibrariesScreenState extends State } void _onTabChanged() { - // Save tab index when changed + // Save tab index when changed (but not when restoring from storage) if (_selectedLibraryGlobalKey != null && !_tabController.indexIsChanging) { - StorageService.getInstance().then((storage) { - storage.saveLibraryTab( - _selectedLibraryGlobalKey!, - _tabController.index, - ); - }); + // Only save if this was a user-initiated tab change, not a restore + if (!_isRestoringTab) { + StorageService.getInstance().then((storage) { + storage.saveLibraryTab( + _selectedLibraryGlobalKey!, + _tabController.index, + ); + }); + + // Focus first item in the current tab (only for user-initiated changes) + // But not when navigating via tab bar (suppressAutoFocus is true) + if (!_suppressAutoFocus) { + _focusCurrentTab(); + } + } } // Rebuild to update chip selection state setState(() {}); } + /// Focus the first item in the currently active tab + void _focusCurrentTab() { + // Re-enable auto-focus since user is navigating into tab content + setState(() { + _suppressAutoFocus = false; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + State? tabState; + switch (_tabController.index) { + case 0: + tabState = _recommendedTabKey.currentState; + break; + case 1: + tabState = _browseTabKey.currentState; + break; + case 2: + tabState = _collectionsTabKey.currentState; + break; + case 3: + tabState = _playlistsTabKey.currentState; + break; + } + + if (tabState != null) { + (tabState as dynamic).focusFirstItem(); + } else { + // State not available yet, retry after another frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusCurrentTabImmediate(); + }); + } + }); + } + + /// Focus without additional frame delay (used for retry) + void _focusCurrentTabImmediate() { + State? tabState; + switch (_tabController.index) { + case 0: + tabState = _recommendedTabKey.currentState; + break; + case 1: + tabState = _browseTabKey.currentState; + break; + case 2: + tabState = _collectionsTabKey.currentState; + break; + case 3: + tabState = _playlistsTabKey.currentState; + break; + } + + if (tabState != null) { + (tabState as dynamic).focusFirstItem(); + } + } + + /// Handle when a tab's data has finished loading + void _handleTabDataLoaded(int tabIndex) { + // Track that this tab has loaded + _loadedTabs.add(tabIndex); + + // Don't auto-focus if suppressed (e.g., when navigating via tab bar) + if (_suppressAutoFocus) return; + + // Only focus if this is the currently active tab + if (_tabController.index == tabIndex && mounted) { + // Use post-frame callback to ensure the widget tree is fully built + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _tabController.index == tabIndex && !_suppressAutoFocus) { + _focusCurrentTab(); + } + }); + } + } + + /// Called by parent when the Libraries screen becomes visible. + /// If the active tab has already loaded data (often the case after preloading + /// while on another main tab), re-request focus so the first item is focused + /// once the screen is actually shown. + void focusActiveTabIfReady() { + if (_selectedLibraryGlobalKey == null) return; + _focusCurrentTab(); + } + + /// Focus the currently selected tab chip in the tab bar. + /// Called when BACK is pressed in tab content. + void focusTabBar() { + setState(() { + _suppressAutoFocus = true; + }); + final focusNode = _getTabChipFocusNode(_tabController.index); + focusNode.requestFocus(); + } + + /// Get the focus node for a tab chip by index + FocusNode _getTabChipFocusNode(int index) { + switch (index) { + case 0: + return _recommendedTabChipFocusNode; + case 1: + return _browseTabChipFocusNode; + case 2: + return _collectionsTabChipFocusNode; + case 3: + return _playlistsTabChipFocusNode; + default: + return _recommendedTabChipFocusNode; + } + } + + /// Handle BACK from tab bar - navigate to sidenav + void _onTabBarBack() { + final focusScope = MainScreenFocusScope.of(context); + focusScope?.focusSidebar(); + } + @override void dispose() { _tabController.removeListener(_onTabChanged); _tabController.dispose(); _cancelToken?.cancel(); + _recommendedTabChipFocusNode.dispose(); + _browseTabChipFocusNode.dispose(); + _collectionsTabChipFocusNode.dispose(); + _playlistsTabChipFocusNode.dispose(); super.dispose(); } @@ -329,6 +483,8 @@ class _LibrariesScreenState extends State _updateState(() { _selectedLibraryGlobalKey = libraryGlobalKey; _errorMessage = null; + // Clear loaded tabs tracking for new library + _loadedTabs.clear(); // Only clear filters when explicitly changing library (not on initial load) if (isChangingLibrary) { _selectedFilters.clear(); @@ -347,11 +503,28 @@ class _LibrariesScreenState extends State // Restore saved tab index for this library final savedTabIndex = storage.getLibraryTab(libraryGlobalKey); if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { + // Set flag to prevent _onTabChanged from triggering focus + _isRestoringTab = true; _updateState(() { _tabController.index = savedTabIndex; }); + // Clear flag after the tab change has been processed + WidgetsBinding.instance.addPostFrameCallback((_) { + _isRestoringTab = false; + }); } + // Focus is handled by onDataLoaded callbacks from each tab. + // However, on first load the tab might finish loading before the tab index + // is restored. Check if the current tab has already loaded and focus if so. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + _selectedLibraryGlobalKey == libraryGlobalKey && + _loadedTabs.contains(_tabController.index)) { + _focusCurrentTab(); + } + }); + // Clear filters in storage when changing library if (isChangingLibrary) { await storage.saveLibraryFilters({}, sectionId: libraryGlobalKey); @@ -834,26 +1007,45 @@ class _LibrariesScreenState extends State Widget _buildTabChip(String label, int index) { final isSelected = _tabController.index == index; - final t = tokens(context); + const tabCount = 4; // Recommended, Browse, Collections, Playlists - return ChoiceChip( - label: Text(label), - selected: isSelected, - onSelected: (selected) { - if (selected) { + return FocusableTabChip( + label: label, + isSelected: isSelected, + focusNode: _getTabChipFocusNode(index), + onSelect: () { + if (isSelected) { + // Already selected - navigate to tab content + _focusCurrentTab(); + } else { + // Switch to this tab setState(() { _tabController.index = index; }); } }, - backgroundColor: t.surface, - selectedColor: t.text, - side: BorderSide(color: t.outline), - labelStyle: TextStyle( - color: isSelected ? t.bg : t.text, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - ), - showCheckmark: false, + onNavigateLeft: index > 0 + ? () { + final newIndex = index - 1; + setState(() { + _suppressAutoFocus = true; + _tabController.index = newIndex; + }); + _getTabChipFocusNode(newIndex).requestFocus(); + } + : null, + onNavigateRight: index < tabCount - 1 + ? () { + final newIndex = index + 1; + setState(() { + _suppressAutoFocus = true; + _tabController.index = newIndex; + }); + _getTabChipFocusNode(newIndex).requestFocus(); + } + : null, + onNavigateDown: _focusCurrentTab, + onBack: _onTabBarBack, ); } @@ -1047,6 +1239,7 @@ class _LibrariesScreenState extends State if (_selectedLibraryGlobalKey != null) SliverFillRemaining( child: TabBarView( + key: ValueKey(_selectedLibraryGlobalKey), controller: _tabController, children: [ LibraryRecommendedTab( @@ -1054,24 +1247,40 @@ class _LibrariesScreenState extends State library: _allLibraries.firstWhere( (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), + isActive: _tabController.index == 0, + suppressAutoFocus: _suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(0), + onBack: focusTabBar, ), LibraryBrowseTab( key: _browseTabKey, library: _allLibraries.firstWhere( (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), + isActive: _tabController.index == 1, + suppressAutoFocus: _suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(1), + onBack: focusTabBar, ), LibraryCollectionsTab( key: _collectionsTabKey, library: _allLibraries.firstWhere( (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), + isActive: _tabController.index == 2, + suppressAutoFocus: _suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(2), + onBack: focusTabBar, ), LibraryPlaylistsTab( key: _playlistsTabKey, library: _allLibraries.firstWhere( (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), + isActive: _tabController.index == 3, + suppressAutoFocus: _suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(3), + onBack: focusTabBar, ), ], ), @@ -1123,12 +1332,126 @@ class _LibraryManagementSheet extends StatefulWidget { class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { late List _tempLibraries; + // Keyboard navigation state + int _focusedIndex = 0; + int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button + int? _movingIndex; // Non-null when in move mode + int? _originalIndex; // Original position before move (for cancel) + List? _originalOrder; // Original order before move (for cancel) + final FocusNode _listFocusNode = FocusNode(); + @override void initState() { super.initState(); _tempLibraries = List.from(widget.allLibraries); } + @override + void dispose() { + _listFocusNode.dispose(); + super.dispose(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey; + + if (_movingIndex != null) { + // Move mode - arrows reorder the item + if (key.isUpKey && _movingIndex! > 0) { + setState(() { + final item = _tempLibraries.removeAt(_movingIndex!); + _tempLibraries.insert(_movingIndex! - 1, item); + _movingIndex = _movingIndex! - 1; + _focusedIndex = _movingIndex!; + }); + return KeyEventResult.handled; + } + if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { + setState(() { + final item = _tempLibraries.removeAt(_movingIndex!); + _tempLibraries.insert(_movingIndex! + 1, item); + _movingIndex = _movingIndex! + 1; + _focusedIndex = _movingIndex!; + }); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + // Confirm move - apply the reorder + widget.onReorder(_tempLibraries); + setState(() { + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + if (key.isBackKey) { + // Cancel move - restore original position + setState(() { + if (_originalOrder != null) { + _tempLibraries = List.from(_originalOrder!); + } + _focusedIndex = _originalIndex ?? 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + } else { + // Navigation mode + if (key.isUpKey && _focusedIndex > 0) { + setState(() { + _focusedIndex--; + _focusedColumn = 0; // Reset to row when changing rows + }); + return KeyEventResult.handled; + } + if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { + setState(() { + _focusedIndex++; + _focusedColumn = 0; // Reset to row when changing rows + }); + return KeyEventResult.handled; + } + if (key.isLeftKey && _focusedColumn > 0) { + setState(() => _focusedColumn--); + return KeyEventResult.handled; + } + if (key.isRightKey && _focusedColumn < 2) { + setState(() => _focusedColumn++); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_focusedColumn == 0) { + // Enter move mode + setState(() { + _movingIndex = _focusedIndex; + _originalIndex = _focusedIndex; + _originalOrder = List.from(_tempLibraries); + }); + } else if (_focusedColumn == 1) { + // Toggle visibility + final library = _tempLibraries[_focusedIndex]; + widget.onToggleVisibility(library); + } else if (_focusedColumn == 2) { + // Show options menu + final library = _tempLibraries[_focusedIndex]; + _showLibraryMenuBottomSheet(context, library); + } + return KeyEventResult.handled; + } + if (key.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + void _reorderLibraries(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { @@ -1287,8 +1610,14 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { // Library list (grouped by server if multiple servers) Expanded( - child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), + child: Focus( + focusNode: _listFocusNode, + autofocus: InputModeTracker.isKeyboardMode(context), + onKeyEvent: _handleKeyEvent, + child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), + ), ), + ], ); }, @@ -1301,6 +1630,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { Set hiddenLibraryKeys, ) { final nonUniqueNames = _getNonUniqueLibraryNames(); + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); return ReorderableListView.builder( scrollController: scrollController, @@ -1312,11 +1642,16 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final library = _tempLibraries[index]; final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null; + final isFocused = isKeyboardMode && index == _focusedIndex; + final isMoving = index == _movingIndex; return _buildLibraryTile( library, index, hiddenLibraryKeys, showServerName: showServerName, + isFocused: isFocused, + isMoving: isMoving, + focusedColumn: isFocused ? _focusedColumn : null, ); }, ); @@ -1328,57 +1663,97 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { int index, Set hiddenLibraryKeys, { bool showServerName = false, + bool isFocused = false, + bool isMoving = false, + int? focusedColumn, }) { final isHidden = hiddenLibraryKeys.contains(library.globalKey); + final colorScheme = Theme.of(context).colorScheme; + + // Determine background color based on state + Color? tileColor; + if (isMoving) { + tileColor = colorScheme.primaryContainer; + } else if (isFocused && focusedColumn == 0) { + // Only highlight row when row itself is focused (column 0) + tileColor = colorScheme.surfaceContainerHighest; + } + + // Button focus states + final isVisibilityButtonFocused = isFocused && focusedColumn == 1; + final isOptionsButtonFocused = isFocused && focusedColumn == 2; return Opacity( key: ValueKey(library.globalKey), opacity: isHidden ? 0.5 : 1.0, - child: ListTile( - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ReorderableDragStartListener( - index: index, - child: Icon( - Icons.drag_indicator, - color: IconTheme.of(context).color?.withValues(alpha: 0.5), - ), - ), - const SizedBox(width: 8), - Icon(_getLibraryIcon(library.type)), - ], + child: Container( + decoration: BoxDecoration( + color: tileColor, ), - title: Text(library.title), - subtitle: showServerName - ? Text( - library.serverName!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context) - .textTheme - .bodySmall - ?.color - ?.withValues(alpha: 0.6), + child: ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ReorderableDragStartListener( + index: index, + child: Icon( + isMoving ? Icons.swap_vert : Icons.drag_indicator, + color: isMoving + ? colorScheme.primary + : IconTheme.of(context).color?.withValues(alpha: 0.5), ), - ) - : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), - tooltip: isHidden - ? t.libraries.showLibrary - : t.libraries.hideLibrary, - onPressed: () => widget.onToggleVisibility(library), - ), - IconButton( - icon: const Icon(Icons.more_vert), - tooltip: t.libraries.libraryOptions, - onPressed: () => _showLibraryMenuBottomSheet(context, library), - ), - ], + ), + const SizedBox(width: 8), + Icon(_getLibraryIcon(library.type)), + ], + ), + title: Text(library.title), + subtitle: showServerName + ? Text( + library.serverName!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withValues(alpha: 0.6), + ), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: isVisibilityButtonFocused + ? BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ) + : null, + child: IconButton( + icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), + tooltip: isHidden + ? t.libraries.showLibrary + : t.libraries.hideLibrary, + onPressed: () => widget.onToggleVisibility(library), + ), + ), + Container( + decoration: isOptionsButtonFocused + ? BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ) + : null, + child: IconButton( + icon: const Icon(Icons.more_vert), + tooltip: t.libraries.libraryOptions, + onPressed: () => _showLibraryMenuBottomSheet(context, library), + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index de4b5d4b..50672056 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -25,11 +25,31 @@ abstract class BaseLibraryTab extends StatefulWidget { final String? viewMode; final String? density; + /// Callback invoked when data has finished loading successfully. + /// Used by parent to trigger focus on the first item. + final VoidCallback? onDataLoaded; + + /// Whether this tab is currently the active/visible tab. + /// Used for internal focus management. + final bool isActive; + + /// Whether to suppress auto-focus when tab becomes active. + /// Used when navigating via tab bar to keep focus on the tab chips. + final bool suppressAutoFocus; + + /// Called when the user presses BACK in the tab content. + /// Used to navigate focus back to the tab bar. + final VoidCallback? onBack; + const BaseLibraryTab({ super.key, required this.library, this.viewMode, this.density, + this.onDataLoaded, + this.isActive = false, + this.suppressAutoFocus = false, + this.onBack, }); } @@ -55,6 +75,10 @@ abstract class BaseLibraryTabState> String? _errorMessage; StreamSubscription? _refreshSubscription; + // Focus management + bool _hasLoadedData = false; + bool _hasFocused = false; + // Getters for subclasses List get items => _items; bool get isLoading => _isLoading; @@ -87,8 +111,22 @@ abstract class BaseLibraryTabState> super.didUpdateWidget(oldWidget); // Reload if library changed if (oldWidget.library.globalKey != widget.library.globalKey) { + // Reset focus state for new library + _hasFocused = false; + _hasLoadedData = false; + // Immediately clear stale data before async load + setState(() { + _items = []; + _isLoading = true; + _errorMessage = null; + }); loadItems(); } + + // Check if we should focus (became active after data loaded) + if (widget.isActive && !oldWidget.isActive) { + _tryFocus(); + } } /// Load items from the API @@ -112,11 +150,32 @@ abstract class BaseLibraryTabState> /// Return null if no refresh stream is needed Stream? getRefreshStream() => null; + /// Try to focus the first item if conditions are met (active + loaded + not yet focused) + void _tryFocus() { + // Don't auto-focus if suppressed (e.g., when navigating via tab bar) + if (widget.suppressAutoFocus) return; + + if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { + _hasFocused = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + focusFirstItem(); + } + }); + } + } + + /// Focus the first item in the tab. Subclasses should override this. + void focusFirstItem() { + // Default implementation - subclasses should override + } + /// Load items with error handling and state management Future loadItems() async { setState(() { _isLoading = true; _errorMessage = null; + _items = []; // Clear items to prevent showing stale data during load }); try { @@ -128,6 +187,17 @@ abstract class BaseLibraryTabState> _items = loadedItems; _isLoading = false; }); + + // Mark data as loaded and try to focus + _hasLoadedData = true; + _tryFocus(); + + // Notify parent that data has loaded + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onDataLoaded!(); + }); + } } catch (e) { if (!mounted) return; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index e122804c..0e31b54f 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../../../../services/plex_client.dart'; @@ -9,7 +10,8 @@ import '../../../models/plex_sort.dart'; import '../../../providers/settings_provider.dart'; import '../../../utils/error_message_utils.dart'; import '../../../utils/grid_size_calculator.dart'; -import '../../../widgets/media_card.dart'; +import '../../../widgets/focusable_media_card.dart'; +import '../../../widgets/focusable_filter_chip.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; @@ -29,11 +31,31 @@ class LibraryBrowseTab extends StatefulWidget { final String? viewMode; final String? density; + /// Callback invoked when data has finished loading successfully. + /// Used by parent to trigger focus on the first item. + final VoidCallback? onDataLoaded; + + /// Whether this tab is currently the active/visible tab. + /// Used for internal focus management. + final bool isActive; + + /// Whether to suppress auto-focus when tab becomes active. + /// Used when navigating via tab bar to keep focus on the tab chips. + final bool suppressAutoFocus; + + /// Called when the user presses BACK in the tab content. + /// Used to navigate focus back to the tab bar. + final VoidCallback? onBack; + const LibraryBrowseTab({ super.key, required this.library, this.viewMode, this.density, + this.onDataLoaded, + this.isActive = false, + this.suppressAutoFocus = false, + this.onBack, }); @override @@ -87,6 +109,18 @@ class _LibraryBrowseTabState extends State int _requestId = 0; static const int _pageSize = 500; + // Focus node for the first item (for programmatic focus) + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'browse_first_item'); + + // Focus nodes for filter chips + final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); + final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip'); + final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip'); + + // Focus management + bool _hasLoadedData = false; + bool _hasFocused = false; + @override void initState() { super.initState(); @@ -98,16 +132,59 @@ class _LibraryBrowseTabState extends State super.didUpdateWidget(oldWidget); // Reload if library changed if (oldWidget.library.globalKey != widget.library.globalKey) { + // Reset focus state for new library + _hasFocused = false; + _hasLoadedData = false; _loadContent(); } + + // Check if we should focus (became active after data loaded) + if (widget.isActive && !oldWidget.isActive) { + _tryFocus(); + } } @override void dispose() { _cancelToken?.cancel(); + _firstItemFocusNode.dispose(); + _groupingChipFocusNode.dispose(); + _filtersChipFocusNode.dispose(); + _sortChipFocusNode.dispose(); super.dispose(); } + /// Try to focus the first item if conditions are met (active + loaded + not yet focused) + void _tryFocus() { + // Don't auto-focus if suppressed (e.g., when navigating via tab bar) + if (widget.suppressAutoFocus) return; + + if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { + _hasFocused = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + focusFirstItem(); + } + }); + } + } + + /// Focus the first item in the grid/list (for tab activation) + void focusFirstItem() { + if (_items.isNotEmpty) { + // Request immediately, then once more on the next frame to handle cases + // where the grid/list attaches after the initial focus attempt. + void request() { + if (mounted && _items.isNotEmpty && !_firstItemFocusNode.hasFocus) { + _firstItemFocusNode.requestFocus(); + } + } + + request(); + WidgetsBinding.instance.addPostFrameCallback((_) => request()); + } + } + Future _loadContent() async { // Cancel any pending request _cancelToken?.cancel(); @@ -123,6 +200,13 @@ class _LibraryBrowseTabState extends State _items = []; _currentPage = 0; _hasMoreItems = true; + // Clear filter/sort state while loading to prevent showing stale options + _filters = []; + _sortOptions = []; + _selectedFilters = {}; + _selectedSort = null; + _isSortDescending = false; + _selectedGrouping = _getDefaultGrouping(); }); try { @@ -234,6 +318,19 @@ class _LibraryBrowseTabState extends State _currentPage++; _isLoading = false; }); + + // On initial load (not pagination), mark data as loaded and try to focus + if (!loadMore) { + _hasLoadedData = true; + _tryFocus(); + + // Notify parent + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onDataLoaded!(); + }); + } + } } catch (e) { _handleLoadError(e, currentRequestId); } @@ -402,37 +499,31 @@ class _LibraryBrowseTabState extends State ); } - Widget _buildFilterChip({ - required IconData icon, - required String label, - required VoidCallback onPressed, - }) { - final colorScheme = Theme.of(context).colorScheme; - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 6), - Text( - label, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), + /// Navigate focus from chips down to the first grid item + void _navigateToGrid() { + if (_items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + + /// Navigate focus from grid up to the grouping chip + void _navigateToChips() { + _groupingChipFocusNode.requestFocus(); + } + + /// Calculate the number of columns in the current grid based on screen width + int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) { + final screenWidth = MediaQuery.of(context).size.width - 16; // Subtract padding + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, ); + return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100); + } + + /// Check if the given index is in the first row of the grid + bool _isFirstRow(int index, int columnCount) { + return index < columnCount; } @override @@ -451,15 +542,20 @@ class _LibraryBrowseTabState extends State mainAxisSize: MainAxisSize.min, children: [ // Grouping chip - _buildFilterChip( + FocusableFilterChip( + focusNode: _groupingChipFocusNode, icon: Icons.category, label: _getGroupingLabel(_selectedGrouping), onPressed: _showGroupingBottomSheet, + onNavigateDown: _navigateToGrid, + onNavigateUp: widget.onBack, + onBack: widget.onBack, ), const SizedBox(width: 8), // Filters chip if (_filters.isNotEmpty && _selectedGrouping != 'folders') - _buildFilterChip( + FocusableFilterChip( + focusNode: _filtersChipFocusNode, icon: Icons.filter_alt, label: _selectedFilters.isEmpty ? t.libraries.filters @@ -467,15 +563,22 @@ class _LibraryBrowseTabState extends State count: _selectedFilters.length, ), onPressed: _showFiltersBottomSheet, + onNavigateDown: _navigateToGrid, + onNavigateUp: widget.onBack, + onBack: widget.onBack, ), if (_filters.isNotEmpty && _selectedGrouping != 'folders') const SizedBox(width: 8), // Sort chip if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders') - _buildFilterChip( + FocusableFilterChip( + focusNode: _sortChipFocusNode, icon: Icons.sort, label: _selectedSort?.title ?? t.libraries.sort, onPressed: _showSortBottomSheet, + onNavigateDown: _navigateToGrid, + onNavigateUp: widget.onBack, + onBack: widget.onBack, ), ], ), @@ -531,32 +634,35 @@ class _LibraryBrowseTabState extends State child: Consumer( builder: (context, settingsProvider, child) { if (settingsProvider.viewMode == ViewMode.list) { - return ClipRect( - child: ListView.builder( - clipBehavior: Clip.none, // Allow focus indicator to overflow - padding: const EdgeInsets.all(8), - itemCount: - _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) => _buildMediaCardItem(index), + // In list view, only the first item can navigate up to chips + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: + _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstRow: index == 0, ), ); } else { - return ClipRect( - child: GridView.builder( - clipBehavior: Clip.none, // Allow focus indicator to overflow - padding: const EdgeInsets.all(8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, + // In grid view, calculate columns and pass to item builder + final columnCount = _getGridColumnCount(context, settingsProvider); + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, ), - itemCount: - _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) => _buildMediaCardItem(index), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: + _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstRow: _isFirstRow(index, columnCount), ), ); } @@ -565,7 +671,7 @@ class _LibraryBrowseTabState extends State ); } - Widget _buildMediaCardItem(int index) { + Widget _buildMediaCardItem(int index, {required bool isFirstRow}) { if (index >= _items.length) { return const Padding( padding: EdgeInsets.all(16.0), @@ -573,10 +679,13 @@ class _LibraryBrowseTabState extends State ); } final item = _items[index]; - return MediaCard( + return FocusableMediaCard( key: Key(item.ratingKey), item: item, + focusNode: index == 0 ? _firstItemFocusNode : null, onRefresh: updateItem, + onNavigateUp: isFirstRow ? _navigateToChips : null, + onBack: widget.onBack, ); } } diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index d0fcfe6b..5eec1b11 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -13,6 +13,10 @@ class LibraryCollectionsTab extends BaseLibraryTab { required super.library, super.viewMode, super.density, + super.onDataLoaded, + super.isActive, + super.suppressAutoFocus, + super.onBack, }); @override @@ -21,6 +25,23 @@ class LibraryCollectionsTab extends BaseLibraryTab { class _LibraryCollectionsTabState extends BaseLibraryTabState { + // Focus node for the first item (for programmatic focus) + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'collections_first_item'); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + + /// Focus the first item in the grid/list (for tab activation) + @override + void focusFirstItem() { + if (items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + @override IconData get emptyIcon => Icons.collections; @@ -48,6 +69,8 @@ class _LibraryCollectionsTabState return AdaptiveMediaGrid( items: items, onRefresh: loadItems, + firstItemFocusNode: _firstItemFocusNode, + onBack: widget.onBack, ); } } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 71b898f5..9e96b9bd 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -5,7 +5,7 @@ import '../../../providers/settings_provider.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../services/settings_service.dart' show ViewMode; import '../../../utils/grid_size_calculator.dart'; -import '../../../widgets/media_card.dart'; +import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; import 'base_library_tab.dart'; @@ -17,6 +17,10 @@ class LibraryPlaylistsTab extends BaseLibraryTab { required super.library, super.viewMode, super.density, + super.onDataLoaded, + super.isActive, + super.suppressAutoFocus, + super.onBack, }); @override @@ -25,6 +29,23 @@ class LibraryPlaylistsTab extends BaseLibraryTab { class _LibraryPlaylistsTabState extends BaseLibraryTabState { + // Focus node for the first item (for programmatic focus) + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'playlists_first_item'); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + + /// Focus the first item in the grid/list (for tab activation) + @override + void focusFirstItem() { + if (items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + @override IconData get emptyIcon => Icons.playlist_play; @@ -82,10 +103,12 @@ class _LibraryPlaylistsTabState } Widget _buildPlaylistItem(PlexPlaylist playlist, int index) { - return MediaCard( + return FocusableMediaCard( key: Key(playlist.ratingKey), item: playlist, + focusNode: index == 0 ? _firstItemFocusNode : null, onListRefresh: loadItems, + onBack: widget.onBack, ); } } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index ca0a527a..8d482260 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -11,7 +11,14 @@ import 'base_library_tab.dart'; /// Recommended tab for library screen /// Shows library-specific hubs and recommendations, including dedicated Continue Watching class LibraryRecommendedTab extends BaseLibraryTab { - const LibraryRecommendedTab({super.key, required super.library}); + const LibraryRecommendedTab({ + super.key, + required super.library, + super.onDataLoaded, + super.isActive, + super.suppressAutoFocus, + super.onBack, + }); @override State createState() => _LibraryRecommendedTabState(); @@ -20,6 +27,9 @@ class LibraryRecommendedTab extends BaseLibraryTab { class _LibraryRecommendedTabState extends BaseLibraryTabState with ItemUpdatable { + /// GlobalKeys for each hub section to enable vertical navigation + final List> _hubKeys = []; + @override PlexClient get client => getClientForLibrary(); @@ -47,6 +57,9 @@ class _LibraryRecommendedTabState @override Future> loadData() async { + // Clear hub keys before loading new hubs to prevent stale references + _hubKeys.clear(); + // Use server-specific client for this library final client = getClientForLibrary(); @@ -93,8 +106,45 @@ class _LibraryRecommendedTabState return finalHubs; } + /// Ensure we have enough GlobalKeys for all hubs + void _ensureHubKeys(int count) { + while (_hubKeys.length < count) { + _hubKeys.add(GlobalKey()); + } + } + + /// Handle vertical navigation between hubs + bool _handleVerticalNavigation(int hubIndex, bool isUp) { + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + + // Check if target is valid + if (targetIndex < 0 || targetIndex >= _hubKeys.length) { + // At boundary, block navigation + return true; + } + + // Navigate to target hub with column memory + final targetState = _hubKeys[targetIndex].currentState; + if (targetState != null) { + targetState.requestFocusFromMemory(); + return true; + } + + return false; + } + + /// Focus the first item in the first hub (for tab activation) + @override + void focusFirstItem() { + if (_hubKeys.isNotEmpty && items.isNotEmpty) { + _hubKeys[0].currentState?.requestFocusAt(0); + } + } + @override Widget buildContent(List items) { + _ensureHubKeys(items.length); + return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: items.length, @@ -104,6 +154,7 @@ class _LibraryRecommendedTabState hub.hubIdentifier == '_library_continue_watching_'; return HubSection( + key: index < _hubKeys.length ? _hubKeys[index] : null, hub: hub, icon: _getHubIcon(hub), isInContinueWatching: isContinueWatching, @@ -111,6 +162,8 @@ class _LibraryRecommendedTabState onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null, + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, ); }, ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index b4650c3b..1cd2e65d 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../i18n/strings.g.dart'; @@ -20,6 +21,30 @@ import 'libraries/libraries_screen.dart'; import 'search_screen.dart'; import 'settings/settings_screen.dart'; +/// Provides access to the main screen's focus control. +class MainScreenFocusScope extends InheritedWidget { + final VoidCallback focusSidebar; + final VoidCallback focusContent; + final bool isSidebarFocused; + + const MainScreenFocusScope({ + super.key, + required this.focusSidebar, + required this.focusContent, + required this.isSidebarFocused, + required super.child, + }); + + static MainScreenFocusScope? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(MainScreenFocusScope oldWidget) { + return isSidebarFocused != oldWidget.isSidebarFocused; + } +} + class MainScreen extends StatefulWidget { final PlexClient client; @@ -40,6 +65,11 @@ class _MainScreenState extends State with RouteAware { final GlobalKey> _settingsKey = GlobalKey(); final GlobalKey _sideNavKey = GlobalKey(); + // Focus management for sidebar/content switching + final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); + final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); + bool _isSidebarFocused = false; + @override void initState() { super.initState(); @@ -77,9 +107,53 @@ class _MainScreenState extends State with RouteAware { @override void dispose() { routeObserver.unsubscribe(this); + _sidebarFocusScope.dispose(); + _contentFocusScope.dispose(); super.dispose(); } + void _focusSidebar() { + setState(() => _isSidebarFocused = true); + _sidebarFocusScope.requestFocus(); + // Focus the active item after the focus scope has focus + WidgetsBinding.instance.addPostFrameCallback((_) { + _sideNavKey.currentState?.focusActiveItem(); + }); + } + + void _focusContent() { + setState(() => _isSidebarFocused = false); + _contentFocusScope.requestFocus(); + // When content regains focus while on Libraries, retry focusing the active tab + if (_currentIndex == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final librariesState = _librariesKey.currentState; + if (librariesState != null) { + (librariesState as dynamic).focusActiveTabIfReady(); + } + }); + } + } + + KeyEventResult _handleBackKey(KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final isBackKey = event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.goBack || + event.logicalKey == LogicalKeyboardKey.browserBack || + event.logicalKey == LogicalKeyboardKey.gameButtonB; + + if (!isBackKey) return KeyEventResult.ignored; + + // Toggle focus between sidebar and content + if (_isSidebarFocused) { + _focusContent(); + } else { + _focusSidebar(); + } + return KeyEventResult.handled; + } + @override void didPush() { // Called when this route has been pushed (initial navigation) @@ -164,6 +238,7 @@ class _MainScreenState extends State with RouteAware { } void _selectTab(int index) { + final previousIndex = _currentIndex; setState(() { _currentIndex = index; }); @@ -171,6 +246,13 @@ class _MainScreenState extends State with RouteAware { if (index == 0) { _onDiscoverBecameVisible(); } + // Ensure the libraries screen applies focus when brought into view + if (index == 1 && previousIndex != 1) { + final librariesState = _librariesKey.currentState; + if (librariesState != null) { + (librariesState as dynamic).focusActiveTabIfReady(); + } + } // Focus search input when selecting Search tab if (index == 2) { final searchState = _searchKey.currentState; @@ -190,6 +272,7 @@ class _MainScreenState extends State with RouteAware { final librariesState = _librariesKey.currentState; if (librariesState != null) { (librariesState as dynamic).loadLibraryByKey(libraryGlobalKey); + (librariesState as dynamic).focusActiveTabIfReady(); } } @@ -198,20 +281,41 @@ class _MainScreenState extends State with RouteAware { final useSideNav = PlatformDetector.shouldUseSideNavigation(context); if (useSideNav) { - return SideNavigationScope( - child: Row( - children: [ - SideNavigationRail( - key: _sideNavKey, - selectedIndex: _currentIndex, - selectedLibraryKey: _selectedLibraryGlobalKey, - onDestinationSelected: _selectTab, - onLibrarySelected: _selectLibrary, + return Focus( + onKeyEvent: (node, event) => _handleBackKey(event), + child: MainScreenFocusScope( + focusSidebar: _focusSidebar, + focusContent: _focusContent, + isSidebarFocused: _isSidebarFocused, + child: SideNavigationScope( + child: Row( + children: [ + FocusScope( + node: _sidebarFocusScope, + child: SideNavigationRail( + key: _sideNavKey, + selectedIndex: _currentIndex, + selectedLibraryKey: _selectedLibraryGlobalKey, + onDestinationSelected: (index) { + _selectTab(index); + _focusContent(); + }, + onLibrarySelected: (key) { + _selectLibrary(key); + _focusContent(); + }, + ), + ), + Expanded( + child: FocusScope( + node: _contentFocusScope, + autofocus: true, + child: IndexedStack(index: _currentIndex, children: _screens), + ), + ), + ], ), - Expanded( - child: IndexedStack(index: _currentIndex, children: _screens), - ), - ], + ), ), ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 398f7052..7f965709 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,7 +1,10 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; import '../widgets/plex_optimized_image.dart'; import '../utils/plex_image_helper.dart'; @@ -352,6 +355,14 @@ class _MediaDetailScreenState extends State { } } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context, _watchStateChanged); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { // Use full metadata if loaded, otherwise use passed metadata @@ -360,9 +371,12 @@ class _MediaDetailScreenState extends State { // Show loading state while fetching full metadata if (_isLoadingMetadata) { - return Scaffold( - appBar: AppBar(), - body: const Center(child: CircularProgressIndicator()), + return Focus( + onKeyEvent: _handleKeyEvent, + child: Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ), ); } @@ -371,7 +385,9 @@ class _MediaDetailScreenState extends State { final isDesktop = size.width > 600; final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; - return Scaffold( + return Focus( + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( controller: _scrollController, slivers: [ @@ -719,6 +735,7 @@ class _MediaDetailScreenState extends State { child: SizedBox( height: 48, child: FilledButton.icon( + autofocus: InputModeTracker.isKeyboardMode(context), onPressed: () async { // For TV shows, play the OnDeck episode if available // Otherwise, play the first episode of the first season @@ -1090,6 +1107,7 @@ class _MediaDetailScreenState extends State { ), ], ), + ), ); } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 90394e4a..01514955 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../focus/dpad_navigator.dart'; import '../../services/plex_client.dart'; import '../../models/plex_playlist.dart'; import '../../models/plex_metadata.dart'; @@ -264,9 +266,20 @@ class _PlaylistDetailScreenState } } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar( @@ -348,6 +361,7 @@ class _PlaylistDetailScreenState ), ], ), + ), ); } } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 40e3e1b5..e02b2f21 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../services/plex_client.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/input_mode_tracker.dart'; import '../widgets/plex_optimized_image.dart'; import '../models/plex_metadata.dart'; import '../utils/provider_extensions.dart'; @@ -80,9 +83,19 @@ class _SeasonDetailScreenState extends State } } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context, _watchStateChanged); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return Scaffold( + return Focus( + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar( @@ -123,6 +136,7 @@ class _SeasonDetailScreenState extends State return _EpisodeCard( episode: episode, client: _client, + autofocus: index == 0 && InputModeTracker.isKeyboardMode(context), onTap: () async { await navigateToVideoPlayer(context, metadata: episode); // Refresh episodes when returning from video player @@ -134,6 +148,7 @@ class _SeasonDetailScreenState extends State ), ], ), + ), ); } } @@ -144,12 +159,14 @@ class _EpisodeCard extends StatelessWidget { final PlexClient client; final VoidCallback onTap; final Future Function(String) onRefresh; + final bool autofocus; const _EpisodeCard({ required this.episode, required this.client, required this.onTap, required this.onRefresh, + this.autofocus = false, }); @override @@ -168,6 +185,7 @@ class _EpisodeCard extends StatelessWidget { onTap: onTap, child: InkWell( key: Key(episode.ratingKey), + autofocus: autofocus, onTap: onTap, hoverColor: Theme.of( context, diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 444530f8..20c98d01 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../focus/dpad_navigator.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../i18n/strings.g.dart'; import 'licenses_screen.dart'; @@ -29,12 +31,23 @@ class _AboutScreenState extends State { }); } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { final appName = _appName; final appVersion = _appVersion; - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar(title: Text(t.about.title), pinned: true), @@ -97,6 +110,7 @@ class _AboutScreenState extends State { ), ], ), + ), ); } } diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index 55ef6fdb..8bdba7e0 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../focus/dpad_navigator.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../i18n/strings.g.dart'; @@ -65,13 +67,28 @@ class _LicensesScreenState extends State { } } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { if (_isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: const Scaffold(body: Center(child: CircularProgressIndicator())), + ); } - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar(title: Text(t.screens.licenses), pinned: true), @@ -107,6 +124,7 @@ class _LicensesScreenState extends State { ), ], ), + ), ); } @@ -126,12 +144,23 @@ class _LicenseDetailScreen extends StatelessWidget { const _LicenseDetailScreen({required this.mergedLicense}); + KeyEventResult _handleKeyEvent(BuildContext context, FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { final packageName = mergedLicense.packageName; final licenseEntries = mergedLicense.licenseEntries; - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: (node, event) => _handleKeyEvent(context, node, event), + child: Scaffold( body: CustomScrollView( slivers: [ CustomAppBar(title: Text(packageName), pinned: true), @@ -216,6 +245,7 @@ class _LicenseDetailScreen extends StatelessWidget { ), ], ), + ), ); } } diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart new file mode 100644 index 00000000..077a68eb --- /dev/null +++ b/lib/widgets/focusable_filter_chip.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; + +/// A focusable filter chip that shows a color change when focused. +/// +/// Unlike FocusableWrapper which uses scale + border, this widget +/// uses a background color change to indicate focus state. +class FocusableFilterChip extends StatefulWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + + /// Optional external focus node for programmatic focus control. + final FocusNode? focusNode; + + /// Called when the user presses DOWN from this chip. + final VoidCallback? onNavigateDown; + + /// Called when the user presses UP from this chip. + final VoidCallback? onNavigateUp; + + /// Called when the user presses BACK from this chip. + final VoidCallback? onBack; + + const FocusableFilterChip({ + super.key, + required this.icon, + required this.label, + required this.onPressed, + this.focusNode, + this.onNavigateDown, + this.onNavigateUp, + this.onBack, + }); + + @override + State createState() => _FocusableFilterChipState(); +} + +class _FocusableFilterChipState extends State { + FocusNode? _internalFocusNode; + bool _isFocused = false; + + FocusNode get _focusNode { + return widget.focusNode ?? + (_internalFocusNode ??= FocusNode(debugLabel: 'filter_chip_${widget.label}')); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(FocusableFilterChip oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode?.removeListener(_onFocusChange); + _focusNode.addListener(_onFocusChange); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (mounted) { + setState(() => _isFocused = _focusNode.hasFocus); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + // SELECT key activates the chip + if (event.logicalKey.isSelectKey) { + widget.onPressed(); + return KeyEventResult.handled; + } + + // DOWN arrow navigates to the grid + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + widget.onNavigateDown?.call(); + return KeyEventResult.handled; + } + + // UP arrow navigates to tab bar + if (event.logicalKey == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + return KeyEventResult.handled; + } + + // BACK key navigates to tab bar + if (event.logicalKey.isBackKey && widget.onBack != null) { + widget.onBack!(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + + // Use primary color when focused, surface color when not + final backgroundColor = + showFocus ? colorScheme.primary : colorScheme.surfaceContainerHighest; + final foregroundColor = + showFocus ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: GestureDetector( + onTap: widget.onPressed, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: foregroundColor), + const SizedBox(width: 6), + Text( + widget.label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: foregroundColor), + ), + ], + ), + ), + ), + ); + } + + IconData get icon => widget.icon; +} diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart new file mode 100644 index 00000000..0fbef4a3 --- /dev/null +++ b/lib/widgets/focusable_media_card.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; +import 'media_card.dart'; + +/// A focusable wrapper for MediaCard that handles D-pad navigation. +/// +/// Wraps MediaCard with focus handling for TV/desktop navigation: +/// - Shows scale + border decoration when focused +/// - Handles SELECT key for activation +/// - Accepts optional external focusNode for programmatic focus control +class FocusableMediaCard extends StatefulWidget { + final dynamic item; // PlexMetadata or PlexPlaylist + final double? width; + final double? height; + final void Function(String ratingKey)? onRefresh; + final VoidCallback? onRemoveFromContinueWatching; + final VoidCallback? onListRefresh; + final bool forceGridMode; + final bool isInContinueWatching; + final String? collectionId; + + /// Optional external focus node for programmatic focus control. + /// If not provided, an internal focus node is created. + final FocusNode? focusNode; + + /// Called when the user presses UP and there's no focusable item above. + /// Used to navigate from the top row to filter chips. + final VoidCallback? onNavigateUp; + + /// Called when the user presses BACK. + /// Used to navigate from tab content to tab bar. + final VoidCallback? onBack; + + const FocusableMediaCard({ + super.key, + required this.item, + this.width, + this.height, + this.onRefresh, + this.onRemoveFromContinueWatching, + this.onListRefresh, + this.forceGridMode = false, + this.isInContinueWatching = false, + this.collectionId, + this.focusNode, + this.onNavigateUp, + this.onBack, + }); + + @override + State createState() => _FocusableMediaCardState(); +} + +class _FocusableMediaCardState extends State { + FocusNode? _internalFocusNode; + bool _isFocused = false; + + // Key for accessing MediaCard's state + final GlobalKey _mediaCardKey = GlobalKey(); + + // Long-press detection for SELECT key + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + static const _longPressDuration = Duration(milliseconds: 500); + + FocusNode get _focusNode { + // Use external focus node if provided, otherwise use internal + return widget.focusNode ?? + (_internalFocusNode ??= FocusNode( + debugLabel: 'focusable_media_card_${widget.item.ratingKey}', + )); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(FocusableMediaCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Handle focus node changes + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode?.removeListener(_onFocusChange); + _focusNode.addListener(_onFocusChange); + } + } + + @override + void dispose() { + _longPressTimer?.cancel(); + _focusNode.removeListener(_onFocusChange); + // Only dispose the internal focus node, not external ones + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (mounted) { + final hasFocus = _focusNode.hasFocus; + setState(() => _isFocused = hasFocus); + + // Scroll to center when gaining focus + if (hasFocus) { + _scrollIntoView(); + } + } + } + + /// Scrolls this card into view, centering it vertically in the viewport. + /// Only scrolls if the item is outside the "comfortable zone" (middle 60%) + /// to prevent jitter when navigating horizontally within the same row. + void _scrollIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isFocused) return; + + final renderObject = context.findRenderObject(); + if (renderObject == null) return; + + // Get the scrollable ancestor + final scrollable = Scrollable.maybeOf(context); + if (scrollable == null) return; + + final viewport = scrollable.context.findRenderObject() as RenderBox?; + if (viewport == null) return; + + // Get item's position relative to viewport + final itemBox = renderObject as RenderBox; + final itemPosition = itemBox.localToGlobal( + Offset.zero, + ancestor: viewport, + ); + + // Check if item is already in the comfortable zone + final viewportHeight = viewport.size.height; + final itemHeight = itemBox.size.height; + final itemVerticalCenter = itemPosition.dy + itemHeight / 2; + + // Define comfortable zone - if item center is within middle 60% of viewport, don't scroll + final comfortZoneTop = viewportHeight * 0.2; + final comfortZoneBottom = viewportHeight * 0.8; + + if (itemVerticalCenter >= comfortZoneTop && + itemVerticalCenter <= comfortZoneBottom) { + // Item is in comfortable zone, no need to scroll + return; + } + + // Item is outside comfortable zone, scroll to center + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Handle SELECT key with long-press detection + if (key.isSelectKey) { + if (event is KeyDownEvent) { + // Only start timer on initial press, not repeats + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + // Long press detected - show context menu immediately + if (mounted) { + _mediaCardKey.currentState?.showContextMenu(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + // Consume repeat events to prevent system sounds + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (timerWasActive && _isSelectKeyDown) { + // Timer still active - short press, trigger tap + _mediaCardKey.currentState?.handleTap(); + } + // If timer already fired, context menu was shown - do nothing on key up + _isSelectKeyDown = false; + return KeyEventResult.handled; + } + } + + // Ignore key repeat events for other keys + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + // Context menu key shows context menu + if (key.isContextMenuKey) { + _mediaCardKey.currentState?.showContextMenu(); + return KeyEventResult.handled; + } + + // UP arrow - if callback provided, navigate up (to filter chips) + if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + return KeyEventResult.handled; + } + + // BACK key - navigate to tab bar + if (key.isBackKey && widget.onBack != null) { + widget.onBack!(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: AnimatedScale( + scale: showFocus ? FocusTheme.focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: FocusTheme.defaultBorderRadius, + ), + child: MediaCard( + key: _mediaCardKey, + item: widget.item, + width: widget.width, + height: widget.height, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + onListRefresh: widget.onListRefresh, + forceGridMode: widget.forceGridMode, + isInContinueWatching: widget.isInContinueWatching, + collectionId: widget.collectionId, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart new file mode 100644 index 00000000..f437bcd1 --- /dev/null +++ b/lib/widgets/focusable_tab_chip.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; + +/// A focusable tab chip that shows a color change when focused or selected. +/// +/// Used for tab navigation in LibrariesScreen. Handles: +/// - SELECT key to activate the tab +/// - LEFT/RIGHT arrows to switch between tabs +/// - DOWN arrow to navigate to tab content +/// - BACK key to navigate to sidenav +class FocusableTabChip extends StatefulWidget { + final String label; + final bool isSelected; + final VoidCallback onSelect; + + /// Optional external focus node for programmatic focus control. + final FocusNode? focusNode; + + /// Called when the user presses LEFT from this chip. + /// Should switch to the previous tab. + final VoidCallback? onNavigateLeft; + + /// Called when the user presses RIGHT from this chip. + /// Should switch to the next tab. + final VoidCallback? onNavigateRight; + + /// Called when the user presses DOWN from this chip. + final VoidCallback? onNavigateDown; + + /// Called when the user presses BACK from this chip. + final VoidCallback? onBack; + + const FocusableTabChip({ + super.key, + required this.label, + required this.isSelected, + required this.onSelect, + this.focusNode, + this.onNavigateLeft, + this.onNavigateRight, + this.onNavigateDown, + this.onBack, + }); + + @override + State createState() => _FocusableTabChipState(); +} + +class _FocusableTabChipState extends State { + FocusNode? _internalFocusNode; + bool _isFocused = false; + + FocusNode get _focusNode { + return widget.focusNode ?? + (_internalFocusNode ??= FocusNode(debugLabel: 'tab_chip_${widget.label}')); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(FocusableTabChip oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode?.removeListener(_onFocusChange); + _focusNode.addListener(_onFocusChange); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (mounted) { + setState(() => _isFocused = _focusNode.hasFocus); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // SELECT key activates the tab + if (key.isSelectKey) { + widget.onSelect(); + return KeyEventResult.handled; + } + + // LEFT arrow switches to previous tab + if (key.isLeftKey && widget.onNavigateLeft != null) { + widget.onNavigateLeft!(); + return KeyEventResult.handled; + } + + // RIGHT arrow switches to next tab + if (key.isRightKey && widget.onNavigateRight != null) { + widget.onNavigateRight!(); + return KeyEventResult.handled; + } + + // DOWN arrow navigates to tab content + if (key == LogicalKeyboardKey.arrowDown) { + widget.onNavigateDown?.call(); + return KeyEventResult.handled; + } + + // BACK key navigates to sidenav + if (key.isBackKey && widget.onBack != null) { + widget.onBack!(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + + // Determine background color based on focus and selection state + // - Selected + Focused: slightly dimmed primary (to show focus distinction) + // - Selected only: primary color + // - Focused only: primary color + // - Neither: surface color + Color backgroundColor; + Color foregroundColor; + + if (widget.isSelected && showFocus) { + // Selected + focused: dim the primary color slightly + backgroundColor = Color.lerp( + colorScheme.primary, + colorScheme.surface, + 0.25, + )!; + foregroundColor = colorScheme.onPrimary; + } else if (widget.isSelected || showFocus) { + // Selected or focused (but not both): full primary + backgroundColor = colorScheme.primary; + foregroundColor = colorScheme.onPrimary; + } else { + // Neither selected nor focused + backgroundColor = colorScheme.surfaceContainerHighest; + foregroundColor = colorScheme.onSurfaceVariant; + } + + final isHighlighted = showFocus || widget.isSelected; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: GestureDetector( + onTap: widget.onSelect, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + widget.label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foregroundColor, + fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index 1e1a0475..a751e7c3 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -9,11 +9,13 @@ import '../utils/platform_detector.dart'; class HorizontalScrollWithArrows extends StatefulWidget { final Widget Function(ScrollController) builder; final double scrollAmount; + final ScrollController? controller; const HorizontalScrollWithArrows({ super.key, required this.builder, this.scrollAmount = 0.8, // Scroll by 80% of viewport width by default + this.controller, }); @override @@ -24,6 +26,7 @@ class HorizontalScrollWithArrows extends StatefulWidget { class _HorizontalScrollWithArrowsState extends State { late final ScrollController _scrollController; + late final bool _ownsController; bool _isHovering = false; bool _canScrollLeft = false; bool _canScrollRight = false; @@ -31,7 +34,8 @@ class _HorizontalScrollWithArrowsState @override void initState() { super.initState(); - _scrollController = ScrollController(); + _ownsController = widget.controller == null; + _scrollController = widget.controller ?? ScrollController(); _scrollController.addListener(_updateScrollState); // Initial state update after first frame WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); @@ -40,7 +44,9 @@ class _HorizontalScrollWithArrowsState @override void dispose() { _scrollController.removeListener(_updateScrollState); - _scrollController.dispose(); + if (_ownsController) { + _scrollController.dispose(); + } super.dispose(); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index cacbb6af..cf06c9c3 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,19 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; +import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; +import '../models/plex_playlist.dart'; import '../screens/hub_detail_screen.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import '../screens/playlist/playlist_detail_screen.dart'; +import '../utils/video_player_navigation.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 -class HubSection extends StatelessWidget { +/// +/// 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, @@ -21,113 +44,375 @@ class HubSection extends StatelessWidget { 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 is! KeyDownEvent && event is! KeyRepeatEvent) { + 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; + } + + /// Context menu keys for each item to trigger long press actions + final Map> _itemWrapperKeys = {}; + + GlobalKey<_LockedHubItemWrapperState> _getItemWrapperKey(int index) { + return _itemWrapperKeys.putIfAbsent( + index, + () => GlobalKey<_LockedHubItemWrapperState>(), + ); + } + + void _activateCurrentItem() { + if (_focusedIndex >= widget.hub.items.length) return; + final item = widget.hub.items[_focusedIndex]; + _navigateToItem(item); + } + + void _showContextMenuForCurrentItem() { + // Trigger long press on the wrapper which will bubble to MediaContextMenu + _itemWrapperKeys[_focusedIndex]?.currentState?.triggerLongPress(); + } + + Future _navigateToItem(dynamic item) async { + // Handle playlists + if (item is PlexPlaylist) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen(playlist: item), + ), + ); + return; + } + + final itemType = item.type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await navigateToVideoPlayer(context, metadata: item); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } else if (itemType == 'season') { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen(season: item), + ), + ); + widget.onRefresh?.call(item.ratingKey); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen(metadata: item), + ), + ); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } + } + void _navigateToHubDetail(BuildContext context) { Navigator.push( context, - MaterialPageRoute(builder: (context) => HubDetailScreen(hub: hub)), + 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 + // Hub header (NOT focusable - titles should not be focusable) Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: InkWell( - onTap: hub.more ? () => _navigateToHubDetail(context) : null, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon), - const SizedBox(width: 8), - Expanded( - child: Text( - hub.title, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - maxLines: 1, + child: ExcludeFocus( + child: InkWell( + onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, + borderRadius: BorderRadius.circular(8), + 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 (hub.more) ...[ - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 20), + if (widget.hub.more) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], ], - ], + ), ), ), ), ), - // Hub items (horizontal scroll) - if (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; + // 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 = 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 + ListView padding - // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding - // + 10px for ListView vertical padding (5px top + 5px bottom) - final containerHeight = posterHeight + 56; + // Store item extent for scroll calculations + _itemExtent = cardWidth + 4; // 4px total horizontal padding - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 5, + // 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( + key: _getItemWrapperKey(index), + isFocused: isItemFocused, + onTap: () => _onItemTapped(index), + onLongPress: () { + // Long press on item - this will be caught by + // MediaContextMenu inside MediaCard + }, + child: MediaCard( + key: Key(item.ratingKey), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: + widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isInContinueWatching, + ), + ), + ); + }, ), - itemCount: hub.items.length, - itemBuilder: (context, index) { - final item = hub.items[index]; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 2, - ), - child: MediaCard( - key: Key(item.ratingKey), - item: item, - width: cardWidth, - height: posterHeight, - onRefresh: onRefresh, - onRemoveFromContinueWatching: - onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: isInContinueWatching, - ), - ); - }, ), - ), - ); - }, + ); + }, + ), ) else Padding( @@ -142,4 +427,67 @@ class HubSection extends StatelessWidget { ], ); } + + /// 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 StatefulWidget { + final bool isFocused; + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const _LockedHubItemWrapper({ + super.key, + required this.isFocused, + required this.child, + this.onTap, + this.onLongPress, + }); + + @override + State<_LockedHubItemWrapper> createState() => _LockedHubItemWrapperState(); +} + +class _LockedHubItemWrapperState extends State<_LockedHubItemWrapper> { + /// Trigger long press programmatically (for D-pad context menu key) + void triggerLongPress() { + widget.onLongPress?.call(); + } + + @override + Widget build(BuildContext context) { + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = + widget.isFocused && InputModeTracker.isKeyboardMode(context); + + return GestureDetector( + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: AnimatedScale( + scale: showFocus ? FocusTheme.focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: FocusTheme.defaultBorderRadius, + ), + child: widget.child, + ), + ), + ); + } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 55cb216a..867d594b 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -46,16 +46,26 @@ class MediaCard extends StatefulWidget { }); @override - State createState() => _MediaCardState(); + State createState() => MediaCardState(); } -class _MediaCardState extends State { +class MediaCardState extends State { final _contextMenuKey = GlobalKey(); void _showContextMenu() { _contextMenuKey.currentState?.showContextMenu(context); } + /// Public method to trigger tap action (for keyboard/gamepad SELECT) + void handleTap() { + _handleTap(context); + } + + /// Public method to show context menu (for keyboard/gamepad context menu key) + void showContextMenu() { + _showContextMenu(); + } + String _buildSemanticLabel() { final item = widget.item; final itemType = item.type.toLowerCase(); @@ -101,6 +111,11 @@ class _MediaCardState extends State { } void _handleTap(BuildContext context) async { + // Ignore taps while context menu is open to avoid double-activating + if (_contextMenuKey.currentState?.isContextMenuOpen == true) { + return; + } + // Handle playlists if (widget.item is PlexPlaylist) { await Navigator.push( @@ -246,6 +261,7 @@ class _MediaCardGrid extends StatelessWidget { label: semanticLabel, button: true, child: InkWell( + canRequestFocus: false, // Keyboard handled by FocusableMediaCard onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( @@ -566,6 +582,7 @@ class _MediaCardList extends StatelessWidget { label: semanticLabel, button: true, child: InkWell( + canRequestFocus: false, // Keyboard handled by FocusableMediaCard onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 5862af14..24bae237 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -62,6 +62,9 @@ class MediaContextMenuState extends State { } bool _openedFromKeyboard = false; + bool _isContextMenuOpen = false; + + bool get isContextMenuOpen => _isContextMenuOpen; /// Show the context menu programmatically. /// Used for keyboard/gamepad long-press activation. @@ -111,8 +114,14 @@ class MediaContextMenuState extends State { return context.getClientForServer(serverId); } + void _handleTap() { + if (_isContextMenuOpen) return; + widget.onTap?.call(); + } + void _showContextMenu(BuildContext context) async { - final client = _getClientForItem(); + if (_isContextMenuOpen) return; + _isContextMenuOpen = true; final isPlaylist = widget.item is PlexPlaylist; final metadata = isPlaylist ? null : widget.item as PlexMetadata; @@ -305,98 +314,104 @@ class MediaContextMenuState extends State { ); } - if (!context.mounted) return; + try { + final client = _getClientForItem(); - switch (selected) { - case 'watch': - await _executeAction( - context, - () => client.markAsWatched(metadata!.ratingKey), - t.messages.markedAsWatched, - ); - break; + if (!context.mounted) return; - case 'unwatch': - await _executeAction( - context, - () => client.markAsUnwatched(metadata!.ratingKey), - t.messages.markedAsUnwatched, - ); - break; + switch (selected) { + case 'watch': + await _executeAction( + context, + () => client.markAsWatched(metadata!.ratingKey), + t.messages.markedAsWatched, + ); + break; - case 'remove_from_continue_watching': - // Remove from Continue Watching without affecting watch status or progress - // This preserves the progression for partially watched items - // and doesn't mark unwatched next episodes as watched - try { - await client.removeFromOnDeck(metadata!.ratingKey); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.removedFromContinueWatching)), - ); - // Use specific callback if provided, otherwise fallback to onRefresh - if (widget.onRemoveFromContinueWatching != null) { - widget.onRemoveFromContinueWatching!(); - } else { - widget.onRefresh?.call(metadata.ratingKey); + case 'unwatch': + await _executeAction( + context, + () => client.markAsUnwatched(metadata!.ratingKey), + t.messages.markedAsUnwatched, + ); + break; + + case 'remove_from_continue_watching': + // Remove from Continue Watching without affecting watch status or progress + // This preserves the progression for partially watched items + // and doesn't mark unwatched next episodes as watched + try { + await client.removeFromOnDeck(metadata!.ratingKey); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.removedFromContinueWatching)), + ); + // Use specific callback if provided, otherwise fallback to onRefresh + if (widget.onRemoveFromContinueWatching != null) { + widget.onRemoveFromContinueWatching!(); + } else { + widget.onRefresh?.call(metadata.ratingKey); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.errorLoading(error: e.toString())), + ), + ); } } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.errorLoading(error: e.toString())), - ), - ); - } - } - break; + break; - case 'remove_from_collection': - await _handleRemoveFromCollection(context, metadata!); - break; + case 'remove_from_collection': + await _handleRemoveFromCollection(context, metadata!); + break; - case 'series': - await _navigateToRelated( - context, - metadata!.grandparentRatingKey, - (metadata) => MediaDetailScreen(metadata: metadata), - t.messages.errorLoadingSeries, - ); - break; + case 'series': + await _navigateToRelated( + context, + metadata!.grandparentRatingKey, + (metadata) => MediaDetailScreen(metadata: metadata), + t.messages.errorLoadingSeries, + ); + break; - case 'season': - await _navigateToRelated( - context, - metadata!.parentRatingKey, - (metadata) => SeasonDetailScreen(season: metadata), - t.messages.errorLoadingSeason, - ); - break; + case 'season': + await _navigateToRelated( + context, + metadata!.parentRatingKey, + (metadata) => SeasonDetailScreen(season: metadata), + t.messages.errorLoadingSeason, + ); + break; - case 'fileinfo': - await _showFileInfo(context); - break; + case 'fileinfo': + await _showFileInfo(context); + break; - case 'add_to': - await _showAddToSubmenu(context); - break; + case 'add_to': + await _showAddToSubmenu(context); + break; - case 'shuffle_play': - await _handleShufflePlayWithQueue(context); - break; + case 'shuffle_play': + await _handleShufflePlayWithQueue(context); + break; - case 'play': - await _handlePlay(context, isCollection, isPlaylist); - break; + case 'play': + await _handlePlay(context, isCollection, isPlaylist); + break; - case 'shuffle': - await _handleShuffle(context, isCollection, isPlaylist); - break; + case 'shuffle': + await _handleShuffle(context, isCollection, isPlaylist); + break; - case 'delete': - await _handleDelete(context, isCollection, isPlaylist); - break; + case 'delete': + await _handleDelete(context, isCollection, isPlaylist); + break; + } + } finally { + _isContextMenuOpen = false; } } @@ -1208,7 +1223,7 @@ class MediaContextMenuState extends State { @override Widget build(BuildContext context) { return GestureDetector( - onTap: widget.onTap, + onTap: _handleTap, onTapDown: _storeTapPosition, onLongPress: () => _showContextMenu(context), onSecondaryTapDown: _storeTapPosition, diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index d5a93f91..e471ace2 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -1,8 +1,10 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; import '../models/plex_library.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -35,12 +37,109 @@ class SideNavigationRailState extends State { List _libraries = []; bool _isLoadingLibraries = true; + // Focus nodes for main nav items + late FocusNode _homeFocusNode; + late FocusNode _librariesFocusNode; + late FocusNode _searchFocusNode; + late FocusNode _settingsFocusNode; + + // Focus state tracking + bool _isHomeFocused = false; + bool _isLibrariesFocused = false; + bool _isSearchFocused = false; + bool _isSettingsFocused = false; + + // Map to store library item focus nodes and states + final Map _libraryFocusNodes = {}; + final Set _focusedLibraryKeys = {}; + @override void initState() { super.initState(); + _homeFocusNode = FocusNode(debugLabel: 'nav_home'); + _librariesFocusNode = FocusNode(debugLabel: 'nav_libraries'); + _searchFocusNode = FocusNode(debugLabel: 'nav_search'); + _settingsFocusNode = FocusNode(debugLabel: 'nav_settings'); + + _homeFocusNode.addListener(() => _onFocusChange(_homeFocusNode, () { + setState(() => _isHomeFocused = _homeFocusNode.hasFocus); + })); + _librariesFocusNode.addListener(() => _onFocusChange(_librariesFocusNode, () { + setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus); + })); + _searchFocusNode.addListener(() => _onFocusChange(_searchFocusNode, () { + setState(() => _isSearchFocused = _searchFocusNode.hasFocus); + })); + _settingsFocusNode.addListener(() => _onFocusChange(_settingsFocusNode, () { + setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus); + })); + _loadLibraries(); } + void _onFocusChange(FocusNode node, VoidCallback updateState) { + if (mounted) updateState(); + } + + @override + void dispose() { + _homeFocusNode.dispose(); + _librariesFocusNode.dispose(); + _searchFocusNode.dispose(); + _settingsFocusNode.dispose(); + for (final node in _libraryFocusNodes.values) { + node.dispose(); + } + super.dispose(); + } + + /// Get or create a focus node for a library item + FocusNode _getLibraryFocusNode(String globalKey) { + return _libraryFocusNodes.putIfAbsent( + globalKey, + () { + final node = FocusNode(debugLabel: 'nav_library_$globalKey'); + node.addListener(() { + if (mounted) { + setState(() { + if (node.hasFocus) { + _focusedLibraryKeys.add(globalKey); + } else { + _focusedLibraryKeys.remove(globalKey); + } + }); + } + }); + return node; + }, + ); + } + + /// Focus the currently selected nav item + void focusActiveItem() { + if (widget.selectedLibraryKey != null) { + // A library is selected - focus that library item + final node = _libraryFocusNodes[widget.selectedLibraryKey]; + node?.requestFocus(); + } else { + // Focus main nav item based on selectedIndex + switch (widget.selectedIndex) { + case 0: + _homeFocusNode.requestFocus(); + break; + case 1: + _librariesFocusNode.requestFocus(); + break; + case 2: + _searchFocusNode.requestFocus(); + break; + case 3: + _settingsFocusNode.requestFocus(); + break; + } + } + } + Future _loadLibraries() async { final multiServerProvider = context.read(); @@ -182,7 +281,9 @@ class SideNavigationRailState extends State { selectedIcon: Icons.home, label: Translations.of(context).navigation.home, isSelected: widget.selectedIndex == 0, + isFocused: _isHomeFocused, onTap: () => widget.onDestinationSelected(0), + focusNode: _homeFocusNode, ), const SizedBox(height: 8), @@ -198,7 +299,9 @@ class SideNavigationRailState extends State { selectedIcon: Icons.search, label: Translations.of(context).navigation.search, isSelected: widget.selectedIndex == 2, + isFocused: _isSearchFocused, onTap: () => widget.onDestinationSelected(2), + focusNode: _searchFocusNode, ), const SizedBox(height: 8), @@ -209,7 +312,9 @@ class SideNavigationRailState extends State { selectedIcon: Icons.settings, label: Translations.of(context).navigation.settings, isSelected: widget.selectedIndex == 3, + isFocused: _isSettingsFocused, onTap: () => widget.onDestinationSelected(3), + focusNode: _settingsFocusNode, ), ], ), @@ -226,38 +331,57 @@ class SideNavigationRailState extends State { required IconData selectedIcon, required String label, required bool isSelected, + required bool isFocused, required VoidCallback onTap, + required FocusNode focusNode, + bool autofocus = false, }) { final t = tokens(context); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isSelected ? t.text.withValues(alpha: 0.1) : null, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - isSelected ? selectedIcon : icon, - size: 22, - color: isSelected ? t.text : t.textMuted, - ), - const SizedBox(width: 12), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + return Focus( + focusNode: focusNode, + autofocus: autofocus, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + onTap(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? t.text.withValues(alpha: 0.1) + : isFocused + ? t.text.withValues(alpha: 0.08) + : null, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + isSelected ? selectedIcon : icon, + size: 22, color: isSelected ? t.text : t.textMuted, ), - ), - ], + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? t.text : t.textMuted, + ), + ), + ], + ), ), ), ), @@ -265,57 +389,72 @@ class SideNavigationRailState extends State { } Widget _buildLibrariesSection(List visibleLibraries, dynamic t) { + final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Libraries header with expand/collapse - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { + Focus( + focusNode: _librariesFocusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { setState(() { _librariesExpanded = !_librariesExpanded; }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: - widget.selectedIndex == 1 && - widget.selectedLibraryKey == null - ? t.text.withValues(alpha: 0.1) - : null, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - widget.selectedIndex == 1 - ? Icons.video_library - : Icons.video_library_outlined, - size: 22, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - Translations.of(context).navigation.libraries, - style: TextStyle( - fontSize: 14, - fontWeight: widget.selectedIndex == 1 - ? FontWeight.w600 - : FontWeight.w400, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + setState(() { + _librariesExpanded = !_librariesExpanded; + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isLibrariesSelected + ? t.text.withValues(alpha: 0.1) + : _isLibrariesFocused + ? t.text.withValues(alpha: 0.08) + : null, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + widget.selectedIndex == 1 + ? Icons.video_library + : Icons.video_library_outlined, + size: 22, + color: widget.selectedIndex == 1 ? t.text : t.textMuted, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + Translations.of(context).navigation.libraries, + style: TextStyle( + fontSize: 14, + fontWeight: widget.selectedIndex == 1 + ? FontWeight.w600 + : FontWeight.w400, + color: widget.selectedIndex == 1 ? t.text : t.textMuted, + ), ), ), - ), - Icon( - _librariesExpanded ? Icons.expand_less : Icons.expand_more, - size: 20, - color: t.textMuted, - ), - ], + Icon( + _librariesExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + color: t.textMuted, + ), + ], + ), ), ), ), @@ -385,65 +524,82 @@ class SideNavigationRailState extends State { final isSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == library.globalKey; + final isFocused = _focusedLibraryKeys.contains(library.globalKey); + final focusNode = _getLibraryFocusNode(library.globalKey); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onLibrarySelected(library.globalKey), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.only( - left: 28, - right: 12, - top: 10, - bottom: 10, - ), - decoration: BoxDecoration( - color: isSelected ? t.text.withValues(alpha: 0.1) : null, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - isSelected - ? _getLibraryIconFilled(library.type) - : _getLibraryIcon(library.type), - size: 18, - color: isSelected ? t.text : t.textMuted, - ), - const SizedBox(width: 10), - Expanded( - child: SizedBox( - height: 32, // Fixed height for consistent item sizing - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - library.title, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, - color: isSelected ? t.text : t.textMuted, - ), - overflow: TextOverflow.ellipsis, - ), - if (showServerName) + return Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + widget.onLibrarySelected(library.globalKey); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onLibrarySelected(library.globalKey), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.only( + left: 28, + right: 12, + top: 10, + bottom: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? t.text.withValues(alpha: 0.1) + : isFocused + ? t.text.withValues(alpha: 0.08) + : null, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + isSelected + ? _getLibraryIconFilled(library.type) + : _getLibraryIcon(library.type), + size: 18, + color: isSelected ? t.text : t.textMuted, + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 32, // Fixed height for consistent item sizing + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - library.serverName!, + library.title, style: TextStyle( - fontSize: 9, - color: t.textMuted.withValues(alpha: 0.4), + fontSize: 13, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected ? t.text : t.textMuted, ), overflow: TextOverflow.ellipsis, ), - ], + if (showServerName) + Text( + library.serverName!, + style: TextStyle( + fontSize: 9, + color: t.textMuted.withValues(alpha: 0.4), + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ),