feat: re-implement keyboard navigation

This commit is contained in:
edde746
2025-12-07 13:57:52 +01:00
parent a373477105
commit 223065cab0
34 changed files with 4287 additions and 542 deletions
+6
View File
@@ -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
+133
View File
@@ -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;
}
}
+44
View File
@@ -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<MonoTokens>()?.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,
),
);
}
}
+322
View File
@@ -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<bool>? 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<bool?>? 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<String>(
/// title: Text('Option A'),
/// value: 'a',
/// groupValue: selectedValue,
/// onChanged: (value) => setSelected(value),
/// )
/// ```
class FocusableRadioListItem<T> 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<T?>? 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<T>(
secondary: secondary,
title: title,
subtitle: subtitle,
value: value,
groupValue: groupValue,
onChanged: onChanged,
contentPadding: contentPadding,
),
);
}
}
+278
View File
@@ -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<FocusableScrollSection> createState() => _FocusableScrollSectionState();
}
class _FocusableScrollSectionState extends State<FocusableScrollSection> {
late ScrollController _scrollController;
bool _ownsController = false;
late FocusScopeNode _focusScopeNode;
final List<FocusNode> _itemFocusNodes = [];
int _lastFocusedIndex = 0;
bool _hasFocus = false;
// Global focus memory (shared across sections)
static final Map<String, int> _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;
}
}
+265
View File
@@ -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<bool>? 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<FocusableWrapper> createState() => _FocusableWrapperState();
}
class _FocusableWrapperState extends State<FocusableWrapper>
with SingleTickerProviderStateMixin {
late FocusNode _focusNode;
bool _ownsNode = false;
bool _isFocused = false;
late AnimationController _animationController;
late Animation<double> _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<double>(
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;
}
}
+106
View File
@@ -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<InputModeTracker> createState() => _InputModeTrackerState();
}
class _InputModeTrackerState extends State<InputModeTracker> {
// 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;
}
}
+147
View File
@@ -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<String, int> _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;
}
}
+47
View File
@@ -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,
),
);
}
}
+11 -8
View File
@@ -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<ThemeProvider>(
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(),
),
),
);
},
+15 -1
View File
@@ -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
),
],
),
),
);
}
}
+446 -96
View File
@@ -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<DiscoverScreen>
late AnimationController _indicatorAnimationController;
bool _isAutoScrollPaused = false;
// Hub navigation keys
GlobalKey<HubSectionState>? _continueWatchingHubKey;
final List<GlobalKey<HubSectionState>> _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<DiscoverScreen>
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<HubSectionState>());
}
// Create continue watching hub key if needed
if (_onDeck.isNotEmpty) {
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
}
}
/// Get all hub states (continue watching + other hubs)
List<GlobalKey<HubSectionState>> get _allHubKeys {
final keys = <GlobalKey<HubSectionState>>[];
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<DiscoverScreen>
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<UserProfileProvider>();
_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<DiscoverScreen>
// Reset hero index to avoid sync issues
_currentHeroIndex = 0;
// Create continue watching hub key if needed
if (_onDeck.isNotEmpty) {
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
}
});
// Sync PageController to first page after OnDeck loads
@@ -263,6 +506,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
setState(() {
_hubs = filteredHubs;
_areHubsLoading = false;
_updateHubKeys();
});
appLogger.d('Discover content loaded successfully');
@@ -501,6 +745,60 @@ class _DiscoverScreenState extends State<DiscoverScreen>
);
}
/// 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<String>(
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<DiscoverScreen>
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<UserProfileProvider>(
builder: (context, userProvider, child) {
return PopupMenuButton<String>(
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<String>(
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<DiscoverScreen>
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<DiscoverScreen>
onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching,
isInContinueWatching: true,
onVerticalNavigation: (isUp) =>
_handleVerticalNavigation(0, isUp),
),
),
@@ -629,9 +954,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
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<DiscoverScreen>
),
],
),
),
),
);
}
@@ -1194,62 +1530,76 @@ class _DiscoverScreenState extends State<DiscoverScreen>
? 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,
),
),
),
],
],
),
),
),
);
+15 -1
View File
@@ -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<HubDetailScreen> 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<HubDetailScreen> with Refreshable {
),
],
),
),
);
}
}
+15 -3
View File
@@ -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,
);
},
);
+440 -65
View File
@@ -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<LibrariesScreen>
bool _isInitialLoad = true;
List<String>? _serverOrder; // Cached server order from storage
/// When true, suppress auto-focus in tabs (used when navigating via tab bar)
bool _suppressAutoFocus = false;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
@@ -73,9 +81,21 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<int> _loadedTabs = {};
/// Key for the library dropdown popup menu button
final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>();
// 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<LibrariesScreen>
}
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<LibrariesScreen>
_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<LibrariesScreen>
// 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<LibrariesScreen>
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<LibrariesScreen>
if (_selectedLibraryGlobalKey != null)
SliverFillRemaining(
child: TabBarView(
key: ValueKey(_selectedLibraryGlobalKey),
controller: _tabController,
children: [
LibraryRecommendedTab(
@@ -1054,24 +1247,40 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<PlexLibrary> _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<PlexLibrary>? _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<String> 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<String> 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),
),
),
],
),
),
),
);
@@ -25,11 +25,31 @@ abstract class BaseLibraryTab<T> 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<T, W extends BaseLibraryTab<T>>
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
// Focus management
bool _hasLoadedData = false;
bool _hasFocused = false;
// Getters for subclasses
List<T> get items => _items;
bool get isLoading => _isLoading;
@@ -87,8 +111,22 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
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<T, W extends BaseLibraryTab<T>>
/// Return null if no refresh stream is needed
Stream<void>? 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<void> 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<T, W extends BaseLibraryTab<T>>
_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;
@@ -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<LibraryBrowseTab>
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<LibraryBrowseTab>
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<void> _loadContent() async {
// Cancel any pending request
_cancelToken?.cancel();
@@ -123,6 +200,13 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
_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<LibraryBrowseTab>
_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<LibraryBrowseTab>
);
}
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<LibraryBrowseTab>
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<LibraryBrowseTab>
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<LibraryBrowseTab>
child: Consumer<SettingsProvider>(
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<LibraryBrowseTab>
);
}
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<LibraryBrowseTab>
);
}
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,
);
}
}
@@ -13,6 +13,10 @@ class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
required super.library,
super.viewMode,
super.density,
super.onDataLoaded,
super.isActive,
super.suppressAutoFocus,
super.onBack,
});
@override
@@ -21,6 +25,23 @@ class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
class _LibraryCollectionsTabState
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> {
// 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,
);
}
}
@@ -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<PlexPlaylist> {
required super.library,
super.viewMode,
super.density,
super.onDataLoaded,
super.isActive,
super.suppressAutoFocus,
super.onBack,
});
@override
@@ -25,6 +29,23 @@ class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
class _LibraryPlaylistsTabState
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> {
// 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,
);
}
}
@@ -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<PlexHub> {
const LibraryRecommendedTab({super.key, required super.library});
const LibraryRecommendedTab({
super.key,
required super.library,
super.onDataLoaded,
super.isActive,
super.suppressAutoFocus,
super.onBack,
});
@override
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
@@ -20,6 +27,9 @@ class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
class _LibraryRecommendedTabState
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab>
with ItemUpdatable {
/// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = [];
@override
PlexClient get client => getClientForLibrary();
@@ -47,6 +57,9 @@ class _LibraryRecommendedTabState
@override
Future<List<PlexHub>> 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<HubSectionState>());
}
}
/// 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<PlexHub> 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,
);
},
);
+117 -13
View File
@@ -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<MainScreenFocusScope>();
}
@override
bool updateShouldNotify(MainScreenFocusScope oldWidget) {
return isSidebarFocused != oldWidget.isSidebarFocused;
}
}
class MainScreen extends StatefulWidget {
final PlexClient client;
@@ -40,6 +65,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
final GlobalKey<SideNavigationRailState> _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<MainScreen> 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<MainScreen> with RouteAware {
}
void _selectTab(int index) {
final previousIndex = _currentIndex;
setState(() {
_currentIndex = index;
});
@@ -171,6 +246,13 @@ class _MainScreenState extends State<MainScreen> 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<MainScreen> 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<MainScreen> 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),
),
],
),
),
);
}
+22 -4
View File
@@ -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<MediaDetailScreen> {
}
}
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<MediaDetailScreen> {
// 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<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
),
],
),
),
);
}
@@ -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
),
],
),
),
);
}
}
+19 -1
View File
@@ -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<SeasonDetailScreen>
}
}
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<SeasonDetailScreen>
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<SeasonDetailScreen>
),
],
),
),
);
}
}
@@ -144,12 +159,14 @@ class _EpisodeCard extends StatelessWidget {
final PlexClient client;
final VoidCallback onTap;
final Future<void> 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,
+15 -1
View File
@@ -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<AboutScreen> {
});
}
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<AboutScreen> {
),
],
),
),
);
}
}
+33 -3
View File
@@ -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<LicensesScreen> {
}
}
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<LicensesScreen> {
),
],
),
),
);
}
@@ -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 {
),
],
),
),
);
}
}
+159
View File
@@ -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<FocusableFilterChip> createState() => _FocusableFilterChipState();
}
class _FocusableFilterChipState extends State<FocusableFilterChip> {
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;
}
+263
View File
@@ -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<FocusableMediaCard> createState() => _FocusableMediaCardState();
}
class _FocusableMediaCardState extends State<FocusableMediaCard> {
FocusNode? _internalFocusNode;
bool _isFocused = false;
// Key for accessing MediaCard's state
final GlobalKey<MediaCardState> _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,
),
),
),
);
}
}
+189
View File
@@ -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<FocusableTabChip> createState() => _FocusableTabChipState();
}
class _FocusableTabChipState extends State<FocusableTabChip> {
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,
),
),
),
),
);
}
}
@@ -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<HorizontalScrollWithArrows> {
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();
}
+429 -81
View File
@@ -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<HubSection> createState() => HubSectionState();
}
class HubSectionState extends State<HubSection> {
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<int, GlobalKey<_LockedHubItemWrapperState>> _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<void> _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<bool>(
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,
),
),
);
}
}
+19 -2
View File
@@ -46,16 +46,26 @@ class MediaCard extends StatefulWidget {
});
@override
State<MediaCard> createState() => _MediaCardState();
State<MediaCard> createState() => MediaCardState();
}
class _MediaCardState extends State<MediaCard> {
class MediaCardState extends State<MediaCard> {
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
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<MediaCard> {
}
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(
+95 -80
View File
@@ -62,6 +62,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
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<MediaContextMenu> {
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<MediaContextMenu> {
);
}
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<MediaContextMenu> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onTap: _handleTap,
onTapDown: _storeTapPosition,
onLongPress: () => _showContextMenu(context),
onSecondaryTapDown: _storeTapPosition,
+275 -119
View File
@@ -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<SideNavigationRail> {
List<PlexLibrary> _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<String, FocusNode> _libraryFocusNodes = {};
final Set<String> _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<void> _loadLibraries() async {
final multiServerProvider = context.read<MultiServerProvider>();
@@ -182,7 +281,9 @@ class SideNavigationRailState extends State<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
}
Widget _buildLibrariesSection(List<PlexLibrary> 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<SideNavigationRail> {
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,
),
],
),
),
),
),
],
],
),
),
),
),