Files
plezy/lib/focus/input_mode_tracker.dart
T

145 lines
4.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import '../services/gamepad_service.dart';
import 'dpad_navigator.dart';
import '../services/companion_remote/companion_remote_receiver.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();
// Initialize focus highlight strategy based on starting mode
_updateFocusHighlightStrategy(_mode);
// Listen to hardware keyboard events globally
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
// Register callback for gamepad input to switch to keyboard mode
GamepadService.onGamepadInput = () => _setMode(InputMode.keyboard);
// Register callback for companion remote input to switch to keyboard mode
CompanionRemoteReceiver.onRemoteInput = () => _setMode(InputMode.keyboard);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
GamepadService.onGamepadInput = null;
CompanionRemoteReceiver.onRemoteInput = null;
super.dispose();
}
bool _handleKeyEvent(KeyEvent event) {
// Track back key press state for automatic suppression of stray KeyUp
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only switch to keyboard mode on navigation key down (not repeats, releases,
// or non-navigation keys like volume buttons or letter keys while typing)
if (event is KeyDownEvent && event.logicalKey.isNavigationKey) {
_setMode(InputMode.keyboard);
}
// Return false to let the event continue propagating
return false;
}
void _setMode(InputMode mode) {
if (_mode != mode) {
setState(() => _mode = mode);
}
_updateFocusHighlightStrategy(mode);
}
// Keep Material focus highlights in sync with our input mode so keyboard/gamepad
// navigation immediately shows focus without waiting for a real keypress.
void _updateFocusHighlightStrategy(InputMode mode) {
final desiredStrategy = mode == InputMode.keyboard
? FocusHighlightStrategy.alwaysTraditional
: FocusHighlightStrategy.automatic;
if (FocusManager.instance.highlightStrategy != desiredStrategy) {
FocusManager.instance.highlightStrategy = desiredStrategy;
}
}
@override
Widget build(BuildContext context) {
// On Android TV, don't switch to pointer mode from pointer events
// as D-pad can generate synthetic pointer events that would incorrectly
// trigger pointer mode and show a cursor instead of D-pad focus navigation
if (TvDetectionService.isTVSync()) {
return _InputModeProvider(mode: _mode, child: widget.child);
}
return Listener(
// Switch to pointer mode on mouse activity
onPointerDown: (_) => _setMode(InputMode.pointer),
onPointerHover: (_) => _setMode(InputMode.pointer),
behavior: HitTestBehavior.translucent,
child: MouseRegion(
cursor: _mode == InputMode.keyboard
? SystemMouseCursors.none
: MouseCursor.defer,
child: IgnorePointer(
ignoring: _mode == InputMode.keyboard,
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;
}
}