Files
plezy/lib/focus/focusable_wrapper.dart
T
edde746 4eaf4423a1 refactor: share focus chrome and simplify the TV picker and browse paths
Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
2026-07-26 06:09:49 +02:00

592 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart';
import 'focus_chrome.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'owned_focus_node_binding.dart';
import 'key_event_utils.dart';
String _describeFocusableKey(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
}
void _logFocusableWrapper(String message) {
TextInputDiagnostics.log('FocusableWrapper', message);
}
/// Applies a visual scale without changing hit-test or semantics geometry.
///
/// Focus scale is paint-only: animating a [Transform] marks the transformed
/// subtree's semantics dirty on every frame, which is costly for dense TV
/// grids. Keeping layout and semantics static preserves the same visible
/// motion without rebuilding the accessibility tree.
class _PaintScale extends SingleChildRenderObjectWidget {
const _PaintScale({required this.scale, required super.child});
final double scale;
@override
RenderObject createRenderObject(BuildContext context) => _RenderPaintScale(scale);
@override
void updateRenderObject(BuildContext context, _RenderPaintScale renderObject) {
renderObject.scale = scale;
}
}
class _RenderPaintScale extends RenderProxyBox {
_RenderPaintScale(double scale) : _scale = scale;
final Matrix4 _transform = Matrix4.identity();
double _scale;
set scale(double value) {
if (_scale == value) return;
_scale = value;
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) return;
if (_scale == 1) {
layer = null;
super.paint(context, offset);
return;
}
_transform
..setIdentity()
..setEntry(0, 0, _scale)
..setEntry(1, 1, _scale)
..setTranslationRaw((1 - _scale) * size.width / 2, (1 - _scale) * size.height / 2, 0);
layer = context.pushTransform(
needsCompositing,
offset,
_transform,
super.paint,
oldLayer: layer is TransformLayer ? layer as TransformLayer? : null,
);
}
}
/// 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
/// - Long-press detection for SELECT key
/// - Navigation callbacks (UP, BACK)
class FocusableWrapper extends StatefulWidget {
/// The child widget to wrap.
final Widget child;
/// Called when the item is selected (Enter/Select/GamepadA).
/// For short press when [enableLongPress] is true.
final VoidCallback? onSelect;
/// Called when long press is triggered (hold SELECT key or context menu key).
/// Only triggered if [enableLongPress] is true.
final VoidCallback? onLongPress;
/// Called when focus changes.
final ValueChanged<bool>? onFocusChange;
/// Called when the user presses UP and there's no focusable item above.
final VoidCallback? onNavigateUp;
/// Called when the user presses DOWN and there's no focusable item below.
final VoidCallback? onNavigateDown;
/// Called when the user presses LEFT and there's no focusable item to the left.
final VoidCallback? onNavigateLeft;
/// Called when the user presses RIGHT and there's no focusable item to the right.
final VoidCallback? onNavigateRight;
/// Called when the user presses BACK.
final VoidCallback? onBack;
/// 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;
/// Per-corner radii for the focus indicator; overrides [borderRadius] when
/// set (M3E grouped cards: large outer / small inner corners).
final BorderRadius? borderRadii;
/// 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;
/// Whether to use comfortable zone scrolling (only scroll if item is outside middle 60%).
/// If false, always scrolls to [scrollAlignment].
final bool useComfortableZone;
/// Optional semantic label for accessibility.
final String? semanticLabel;
/// Optional current value announced after [semanticLabel].
final String? semanticValue;
/// Optional checked state for toggle-style controls.
final bool? checked;
/// Whether the wrapper can receive focus.
final bool canRequestFocus;
/// Custom key event handler. Return any non-ignored result to stop default handling.
/// This is called before the default key handling.
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
/// Whether to enable long-press detection for SELECT key.
/// When enabled, holding SELECT triggers [onLongPress] after 500ms.
/// Short press triggers [onSelect].
final bool enableLongPress;
/// Duration for long-press detection.
final Duration longPressDuration;
/// Whether to use background color instead of border for focus indicator.
/// Useful for video controls where outline doesn't look good.
final bool useBackgroundFocus;
/// Custom color for the focus border. Only used when [useBackgroundFocus] is false.
/// Useful for filled buttons where the default primary border blends in.
final Color? focusColor;
/// Whether to disable the scale animation on focus.
/// Useful for elements like sliders where scaling looks odd.
final bool disableScale;
/// Scale used for the focus animation.
final double focusScale;
/// Whether to draw a glow around the focused widget.
final bool useFocusGlow;
/// Skip drawing the focus border here and expose the focus state through a
/// [CardFocusScope] instead, so the child places the border on the exact
/// rect it wants highlighted (e.g. MediaCard's poster image).
final bool delegateFocusBorder;
/// Whether descendants can receive focus.
/// Set to false when the child widget has its own Focus (e.g. buttons)
/// that would compete with this wrapper's focus handling.
final bool descendantsAreFocusable;
const FocusableWrapper({
super.key,
required this.child,
this.onSelect,
this.onLongPress,
this.onFocusChange,
this.onNavigateUp,
this.onNavigateDown,
this.onNavigateLeft,
this.onNavigateRight,
this.onBack,
this.autofocus = false,
this.focusNode,
this.borderRadius = FocusTheme.defaultBorderRadius,
this.borderRadii,
this.autoScroll = true,
this.scrollAlignment = 0.5,
this.useComfortableZone = false,
this.semanticLabel,
this.semanticValue,
this.checked,
this.canRequestFocus = true,
this.onKeyEvent,
this.enableLongPress = false,
this.longPressDuration = const Duration(milliseconds: 500),
this.useBackgroundFocus = false,
this.focusColor,
this.disableScale = false,
this.focusScale = FocusTheme.focusScale,
this.useFocusGlow = false,
this.delegateFocusBorder = false,
this.descendantsAreFocusable = true,
});
@override
State<FocusableWrapper> createState() => _FocusableWrapperState();
}
class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerProviderStateMixin {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode get _focusNode => _focusNodeBinding.node;
bool _isFocused = false;
// Created lazily on first focus/keyboard-mode build: touch scrolling builds
// hundreds of these wrappers and must not pay for a Ticker per card.
AnimationController? _animationController;
Animation<double>? _scaleAnimation;
final _selectLongPress = DpadSelectLongPressController();
@override
void initState() {
super.initState();
_bindFocusNode();
}
void _bindFocusNode() {
_focusNodeBinding.bind(externalNode: widget.focusNode, debugLabel: widget.semanticLabel ?? 'FocusableWrapper');
_focusNode.canRequestFocus = widget.canRequestFocus;
}
AnimationController _ensureAnimationController() {
final existing = _animationController;
if (existing != null) return existing;
final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
_animationController = controller;
_scaleAnimation = _createScaleAnimation(controller);
return controller;
}
Animation<double> _createScaleAnimation(AnimationController controller) {
return Tween<double>(
begin: 1.0,
end: widget.focusScale,
).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutCubic));
}
@override
void didUpdateWidget(FocusableWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle focusNode changes
if (widget.focusNode != oldWidget.focusNode) {
_bindFocusNode();
}
// Update canRequestFocus
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus;
}
if (widget.focusScale != oldWidget.focusScale) {
final controller = _animationController;
if (controller != null) {
_scaleAnimation = _createScaleAnimation(controller);
}
}
}
@override
void dispose() {
_selectLongPress.dispose();
_animationController?.dispose();
_focusNodeBinding.dispose();
super.dispose();
}
void _handleFocusChange(bool hasFocus) {
if (_isFocused != hasFocus) {
setState(() {
_isFocused = hasFocus;
});
// Reset long press state when focus is lost
if (!hasFocus) {
_selectLongPress.reset();
}
// Animate scale
if (hasFocus) {
_ensureAnimationController().forward();
} else {
_animationController?.reverse();
}
// Auto-scroll into view
if (hasFocus && widget.autoScroll) {
_scrollIntoView();
}
// Notify listener
widget.onFocusChange?.call(hasFocus);
}
}
// Extra padding for focus decoration (scale + border extends beyond item bounds)
// Scale 1.02 adds ~1% on each side, plus 2.5px border = ~8px total padding needed
static const double _focusDecorationPadding = 8.0;
void _scrollIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isFocused) return;
final renderObject = context.findRenderObject();
if (renderObject == null) return;
// Find the nearest vertical scrollable that actually has scroll range.
// Skip inner scrollables with no extent (e.g. shrinkWrap ListView
// with NeverScrollableScrollPhysics inside an outer scroll view)
// and horizontal scrollables (e.g. TabBarView) since we only do
// vertical scroll calculations.
var scrollable = Scrollable.maybeOf(context);
while (scrollable != null) {
final pos = scrollable.position;
if (pos.axis == Axis.vertical && pos.maxScrollExtent > pos.minScrollExtent) break;
scrollable = Scrollable.maybeOf(scrollable.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);
final viewportHeight = viewport.size.height;
final itemHeight = itemBox.size.height;
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
// Account for focus decoration when checking item visibility
final itemTop = itemPosition.dy - _focusDecorationPadding;
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
if (widget.useComfortableZone) {
// Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll
final comfortZoneTop = viewportHeight * 0.2;
final comfortZoneBottom = viewportHeight * 0.8;
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
// Item is in comfortable zone, no need to scroll
return;
}
} else {
// When not using comfortable zone, still skip scroll if item is already
// close to target position (prevents jitter when navigating horizontally)
final targetY = viewportHeight * widget.scrollAlignment;
final distance = (itemVerticalCenter - targetY).abs();
// Skip scroll if within half the item height of target
if (distance < itemHeight / 2) {
return;
}
}
// Calculate target scroll offset for the immediate scrollable only.
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
// which can cause issues with nested scroll views (e.g., chips bar scrolling
// out of view when focusing grid items in library browse tab).
final position = scrollable.position;
final currentOffset = position.pixels;
// Target: item center should be at scrollAlignment of viewport
// Add padding to ensure focus decoration is fully visible
final targetViewportY = viewportHeight * widget.scrollAlignment;
var scrollDelta = itemVerticalCenter - targetViewportY;
// If item would be near the top edge, add extra scroll to show focus decoration
final projectedItemTop = itemTop - scrollDelta;
if (projectedItemTop < _focusDecorationPadding) {
scrollDelta -= (_focusDecorationPadding - projectedItemTop);
}
if (!position.maxScrollExtent.isFinite) return;
final targetOffset = (currentOffset + scrollDelta).clamp(position.minScrollExtent, position.maxScrollExtent);
position.animateTo(targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
});
}
// Runs the same activation sequence as FocusableChipStateMixin.handleChipKeyEvent
// but is deliberately kept separate: a wrapper always consumes the context-menu
// key (even with no onLongPress, so a card never leaks it upward) and passes
// every unmapped arrow through to framework traversal, where a chip does the
// opposite on both counts.
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
final diagnosticsEnabled = TextInputDiagnostics.enabled;
KeyEventResult finish(KeyEventResult result, String reason) {
if (diagnosticsEnabled) {
_logFocusableWrapper(
'node=${node.debugLabel} result=$result reason=$reason key=(${_describeFocusableKey(event)}) '
'onNav(up=${widget.onNavigateUp != null},down=${widget.onNavigateDown != null},'
'left=${widget.onNavigateLeft != null},right=${widget.onNavigateRight != null}) '
'onSelect=${widget.onSelect != null} onBack=${widget.onBack != null}',
);
}
return result;
}
if (diagnosticsEnabled) {
_logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})');
}
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
if (event is KeyUpEvent && key.isSelectKey) {
_selectLongPress.reset();
}
return finish(KeyEventResult.handled, 'select-key-up-suppressed');
}
// Call custom key handler first
if (widget.onKeyEvent != null) {
final result = widget.onKeyEvent!(node, event);
if (result != KeyEventResult.ignored) {
return finish(result, 'custom-onKeyEvent');
}
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return finish(backResult, 'onBack');
}
}
// Handle SELECT key with optional long-press detection
if (key.isSelectKey) {
if (widget.enableLongPress) {
final result = _selectLongPress.handleKeyEvent(
event,
duration: widget.longPressDuration,
isOwnerActive: () => mounted,
onShortPress: () => widget.onSelect?.call(),
onLongPress: () => widget.onLongPress?.call(),
);
if (result != KeyEventResult.ignored) {
return finish(result, 'select-long-press');
}
} else if (widget.onSelect != null) {
return finish(handleOneShotSelect(event, widget.onSelect!), 'one-shot-select');
}
}
// Ignore key up events for other keys
if (!event.isActionable) {
return finish(KeyEventResult.ignored, 'non-actionable');
}
// Context menu key
if (key.isContextMenuKey) {
_selectLongPress.reset();
widget.onLongPress?.call();
return finish(KeyEventResult.handled, 'context-menu');
}
// UP arrow - if callback provided, navigate up
if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) {
widget.onNavigateUp!();
return finish(KeyEventResult.handled, 'onNavigateUp');
}
// DOWN arrow - if callback provided, navigate down
if (key == LogicalKeyboardKey.arrowDown && widget.onNavigateDown != null) {
widget.onNavigateDown!();
return finish(KeyEventResult.handled, 'onNavigateDown');
}
// LEFT arrow - if callback provided, navigate left (caller is responsible
// for only providing this callback when the item is at the left edge)
if (key == LogicalKeyboardKey.arrowLeft && widget.onNavigateLeft != null) {
widget.onNavigateLeft!();
return finish(KeyEventResult.handled, 'onNavigateLeft');
}
// RIGHT arrow - if callback provided, navigate right (caller is responsible
// for only providing this callback when the item is at the right edge)
if (key == LogicalKeyboardKey.arrowRight && widget.onNavigateRight != null) {
widget.onNavigateRight!();
return finish(KeyEventResult.handled, 'onNavigateRight');
}
return finish(KeyEventResult.ignored, 'fall-through');
}
@override
Widget build(BuildContext context) {
// Only show focus effects during keyboard/d-pad navigation. In pointer/
// touch mode no card ever shows focus chrome, so skip the animated
// scale/border wrappers entirely — they cost real build time multiplied
// by every card in a grid. The Focus node stays mounted so d-pad
// traversal finds the cards the moment keyboard mode activates (which
// rebuilds this widget via the inherited dependency below).
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final showFocus = _isFocused && isKeyboardMode;
Widget inner;
if (!isKeyboardMode) {
inner = widget.child;
} else {
final duration = FocusTheme.getAnimationDuration(context);
final controller = _ensureAnimationController();
// Update animation duration if theme changes
if (controller.duration != duration) {
controller.duration = duration;
}
final shouldScale = showFocus && !widget.disableScale;
// Keep the card subtree outside the scale builder. Rebuilding media-card
// semantics on every animation tick is substantially more expensive than
// changing the paint transform alone on dense TV grids.
inner = AnimatedBuilder(
animation: _scaleAnimation!,
child: buildFocusChrome(
context,
showFocus: showFocus,
duration: duration,
borderRadius: widget.borderRadius,
borderRadii: widget.borderRadii,
focusColor: widget.focusColor,
useBackgroundFocus: widget.useBackgroundFocus,
useFocusGlow: widget.useFocusGlow,
delegateFocusBorder: widget.delegateFocusBorder,
child: widget.child,
),
builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!),
);
}
Widget result = Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
descendantsAreFocusable: widget.descendantsAreFocusable,
onFocusChange: _handleFocusChange,
onKeyEvent: _handleKeyEvent,
child: inner,
);
// Add semantics if label provided
if (widget.semanticLabel != null) {
result = Semantics(
label: widget.semanticLabel,
value: widget.semanticValue,
button: true,
enabled: widget.onSelect != null,
checked: widget.checked,
onTap: widget.onSelect,
onLongPress: widget.onLongPress,
excludeSemantics: true,
child: result,
);
}
if (widget.onSelect != null || widget.onLongPress != null) {
result = ClickableCursor(child: result);
}
return result;
}
}