feat: re-implement keyboard navigation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user