Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
241 lines
6.8 KiB
Dart
241 lines
6.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart'
|
|
show BuildContext, ListenableBuilder, MouseRegion, StatelessWidget, SystemMouseCursors, Widget;
|
|
|
|
/// Reasons that keep the video-player chrome visible and suppress auto-hide.
|
|
enum PlayerChromeHold { pip, contentStrip, promptInteraction, scrub }
|
|
|
|
/// Focus target to request after chrome has rebuilt visible controls.
|
|
///
|
|
/// Named rather than a bool so the call sites that hand the chrome focus say
|
|
/// what they mean, and so adding a target forces every consumer to decide.
|
|
enum PlayerChromeFocusTarget { playPause }
|
|
|
|
/// Owns video-player chrome visibility and auto-hide policy for one player route.
|
|
class PlayerChromeController extends ChangeNotifier implements ValueListenable<bool> {
|
|
PlayerChromeController({bool initiallyVisible = true})
|
|
: _controlsVisible = initiallyVisible,
|
|
_controlsPresented = initiallyVisible;
|
|
|
|
bool _controlsVisible;
|
|
bool _controlsPresented;
|
|
bool _contentStripVisible = false;
|
|
bool _playing = false;
|
|
bool _hasFirstFrame = true;
|
|
Duration _hideDelay = const Duration(seconds: 3);
|
|
Timer? _hideTimer;
|
|
PlayerChromeFocusTarget? _pendingFocusTarget;
|
|
final Set<PlayerChromeHold> _holds = <PlayerChromeHold>{};
|
|
final Stopwatch _pointerActivityStopwatch = Stopwatch()..start();
|
|
int _lastPointerActivityMs = -1000;
|
|
|
|
@override
|
|
bool get value => _controlsVisible;
|
|
|
|
bool get controlsVisible => _controlsVisible;
|
|
|
|
/// Whether controls may still be visibly rendered during their fade-out.
|
|
bool get controlsPresented => _controlsPresented;
|
|
bool get contentStripVisible => _contentStripVisible;
|
|
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
|
|
PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget;
|
|
|
|
void configure({Duration? hideDelay, bool? hasFirstFrame}) {
|
|
var restartTimer = false;
|
|
if (hideDelay != null && hideDelay != _hideDelay) {
|
|
_hideDelay = hideDelay;
|
|
restartTimer = true;
|
|
}
|
|
if (hasFirstFrame != null && hasFirstFrame != _hasFirstFrame) {
|
|
_hasFirstFrame = hasFirstFrame;
|
|
restartTimer = true;
|
|
}
|
|
if (restartTimer) _startAutoHideForCurrentPlaybackState();
|
|
}
|
|
|
|
void setPlaying(bool playing) {
|
|
if (_playing == playing) return;
|
|
_playing = playing;
|
|
if (!_controlsVisible) return;
|
|
if (playing) {
|
|
startAutoHide();
|
|
} else {
|
|
startPausedAutoHide();
|
|
}
|
|
}
|
|
|
|
void setHasFirstFrame(bool hasFirstFrame) {
|
|
if (_hasFirstFrame == hasFirstFrame) return;
|
|
_hasFirstFrame = hasFirstFrame;
|
|
if (!_hasFirstFrame) {
|
|
cancelAutoHide();
|
|
return;
|
|
}
|
|
_startAutoHideForCurrentPlaybackState();
|
|
}
|
|
|
|
void setContentStripVisible(bool visible) {
|
|
if (_contentStripVisible == visible) return;
|
|
_contentStripVisible = visible;
|
|
if (visible) {
|
|
hold(PlayerChromeHold.contentStrip);
|
|
} else {
|
|
release(PlayerChromeHold.contentStrip);
|
|
}
|
|
}
|
|
|
|
void show({bool restartAutoHide = true, PlayerChromeFocusTarget? focusTarget}) {
|
|
_controlsPresented = true;
|
|
var shouldNotify = false;
|
|
if (focusTarget != null) {
|
|
_pendingFocusTarget = focusTarget;
|
|
shouldNotify = true;
|
|
}
|
|
if (!_controlsVisible) {
|
|
_controlsVisible = true;
|
|
shouldNotify = true;
|
|
}
|
|
if (shouldNotify) notifyListeners();
|
|
if (restartAutoHide) _startAutoHideForCurrentPlaybackState();
|
|
}
|
|
|
|
PlayerChromeFocusTarget? takeFocusTarget() {
|
|
final target = _pendingFocusTarget;
|
|
_pendingFocusTarget = null;
|
|
return target;
|
|
}
|
|
|
|
bool hide({bool ignoreHolds = false}) {
|
|
if (!_controlsVisible) return false;
|
|
if (!ignoreHolds && _holds.isNotEmpty) return false;
|
|
cancelAutoHide();
|
|
_controlsVisible = false;
|
|
if (_contentStripVisible) {
|
|
_contentStripVisible = false;
|
|
_holds.remove(PlayerChromeHold.contentStrip);
|
|
}
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
/// Called when the controls opacity animation reaches its hidden target.
|
|
void markControlsHidden() {
|
|
if (_controlsVisible) return;
|
|
_controlsPresented = false;
|
|
}
|
|
|
|
void toggle() {
|
|
if (_controlsVisible) {
|
|
hide();
|
|
} else {
|
|
show();
|
|
}
|
|
}
|
|
|
|
bool recordPointerActivity() {
|
|
final nowMs = _pointerActivityStopwatch.elapsedMilliseconds;
|
|
final shouldThrottle = _controlsVisible && nowMs - _lastPointerActivityMs < 120;
|
|
if (shouldThrottle) return false;
|
|
_lastPointerActivityMs = nowMs;
|
|
|
|
show(restartAutoHide: false);
|
|
_startAutoHideForCurrentPlaybackState();
|
|
return true;
|
|
}
|
|
|
|
void startAutoHide() {
|
|
_hideTimer?.cancel();
|
|
if (!_hasFirstFrame || _holds.isNotEmpty || !_playing) return;
|
|
_hideTimer = Timer(_hideDelay, () {
|
|
if (_playing && _hasFirstFrame) hide();
|
|
});
|
|
}
|
|
|
|
void startPausedAutoHide() {
|
|
_hideTimer?.cancel();
|
|
if (!_controlsVisible || !_hasFirstFrame || _holds.isNotEmpty) return;
|
|
_hideTimer = Timer(_hideDelay, hide);
|
|
}
|
|
|
|
void _startAutoHideForCurrentPlaybackState() {
|
|
if (!_controlsVisible) {
|
|
cancelAutoHide();
|
|
return;
|
|
}
|
|
if (_playing) {
|
|
startAutoHide();
|
|
} else {
|
|
startPausedAutoHide();
|
|
}
|
|
}
|
|
|
|
void restartAutoHideForCurrentPlaybackState() => _startAutoHideForCurrentPlaybackState();
|
|
|
|
void hideForPointerExit() {
|
|
if (_holds.contains(PlayerChromeHold.pip)) return;
|
|
hide(ignoreHolds: true);
|
|
}
|
|
|
|
void cancelAutoHide() {
|
|
_hideTimer?.cancel();
|
|
_hideTimer = null;
|
|
}
|
|
|
|
void hold(PlayerChromeHold hold) {
|
|
if (!_holds.add(hold)) return;
|
|
cancelAutoHide();
|
|
if (!_controlsVisible) {
|
|
_controlsVisible = true;
|
|
}
|
|
_controlsPresented = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void release(PlayerChromeHold hold, {bool notify = true, bool restartAutoHide = true}) {
|
|
if (!_holds.remove(hold)) return;
|
|
if (notify) notifyListeners();
|
|
if (restartAutoHide && _holds.isEmpty) _startAutoHideForCurrentPlaybackState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
cancelAutoHide();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// Defines the pointer boundary for all interactive video-player chrome.
|
|
class PlayerChromeInteractionRegion extends StatelessWidget {
|
|
final PlayerChromeController controller;
|
|
final bool hideOnExit;
|
|
final Widget child;
|
|
|
|
const PlayerChromeInteractionRegion({
|
|
super.key,
|
|
required this.controller,
|
|
required this.hideOnExit,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: controller,
|
|
builder: (context, _) {
|
|
return MouseRegion(
|
|
cursor: controller.controlsVisible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
|
onHover: (_) => controller.recordPointerActivity(),
|
|
onExit: (_) {
|
|
if (!hideOnExit) return;
|
|
controller.cancelAutoHide();
|
|
controller.hideForPointerExit();
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|