refactor(tvos): publish menu passthrough directly, trim untuned remote-touch knobs
TvosMenuPolicyPublisher batched an idempotent fire-and-forget publish behind a transaction-depth counter that could never exceed one across its three non-nesting call sites — the call sites now publish directly and the pure predicate keeps its test coverage. AppleTvRemoteTouchService exposed 14 injectable knobs of which only six are exercised anywhere (verified per knob); the other eight are private constants or internal construction now.
This commit is contained in:
@@ -152,33 +152,6 @@ bool shouldPassTvosMenuToSystem({
|
||||
isCurrentTabRoot;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class TvosMenuPolicyPublisher {
|
||||
TvosMenuPolicyPublisher(this._compute, this._publish);
|
||||
|
||||
final ValueGetter<bool> _compute;
|
||||
final ValueChanged<bool> _publish;
|
||||
int _transactionDepth = 0;
|
||||
|
||||
void run(VoidCallback transaction) {
|
||||
_transactionDepth++;
|
||||
try {
|
||||
transaction();
|
||||
} finally {
|
||||
_transactionDepth--;
|
||||
if (_transactionDepth == 0) {
|
||||
_publish(_compute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void update() {
|
||||
if (_transactionDepth == 0) {
|
||||
_publish(_compute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
enum ProfileInvalidationAction { none, invalidateNow }
|
||||
|
||||
@@ -282,7 +255,6 @@ class _MainScreenState extends State<MainScreen>
|
||||
bool _isSidebarFocused = false;
|
||||
bool _isSidebarInteractionExpanded = false;
|
||||
bool _isOverlaySheetOpen = false;
|
||||
late final TvosMenuPolicyPublisher _tvosMenuPolicyPublisher;
|
||||
|
||||
/// The binder is now owned by a top-level [Provider] (see main.dart) so
|
||||
/// the splash can await its first settle before navigating here. We just
|
||||
@@ -329,7 +301,6 @@ class _MainScreenState extends State<MainScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tvosMenuPolicyPublisher = TvosMenuPolicyPublisher(() => _shouldPassTvosMenuToSystem, _setTvosMenuPassthrough);
|
||||
_isOffline = widget.isOfflineMode;
|
||||
_offlineUntilConnected = widget.isOfflineMode;
|
||||
|
||||
@@ -1235,13 +1206,9 @@ class _MainScreenState extends State<MainScreen>
|
||||
unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled));
|
||||
}
|
||||
|
||||
void _runNavigationTransaction(VoidCallback transaction) {
|
||||
_tvosMenuPolicyPublisher.run(transaction);
|
||||
}
|
||||
|
||||
void _updateTvosMenuPassthrough() {
|
||||
if (!mounted) return;
|
||||
_tvosMenuPolicyPublisher.update();
|
||||
_setTvosMenuPassthrough(_shouldPassTvosMenuToSystem);
|
||||
}
|
||||
|
||||
/// Suppress stray back events after a child route pops.
|
||||
@@ -1553,10 +1520,8 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
void _openSettings() {
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||
_runNavigationTransaction(() {
|
||||
_selectTab(NavigationTabId.settings);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
});
|
||||
_selectTab(NavigationTabId.settings);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1830,17 +1795,13 @@ class _MainScreenState extends State<MainScreen>
|
||||
isReconnecting: _isReconnecting,
|
||||
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
|
||||
onDestinationSelected: (tab) {
|
||||
_runNavigationTransaction(() {
|
||||
final restorePreviousFocus = tab == _currentTab;
|
||||
_selectTab(tab);
|
||||
_focusContent(restorePreviousFocus: restorePreviousFocus);
|
||||
});
|
||||
final restorePreviousFocus = tab == _currentTab;
|
||||
_selectTab(tab);
|
||||
_focusContent(restorePreviousFocus: restorePreviousFocus);
|
||||
},
|
||||
onLibrarySelected: (key) {
|
||||
_runNavigationTransaction(() {
|
||||
_selectLibrary(key);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
});
|
||||
_selectLibrary(key);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
},
|
||||
onNavigateToContent: _focusContent,
|
||||
onReconnect: _triggerReconnect,
|
||||
|
||||
@@ -18,6 +18,14 @@ class AppleTvRemotePlayPauseAction {
|
||||
const AppleTvRemotePlayPauseAction({required this.source, this.detail});
|
||||
}
|
||||
|
||||
const double _axisSwitchDominanceRatio = 1.5;
|
||||
const Duration _swipeRepeatInterval = Duration(milliseconds: 140);
|
||||
// Device-tuned on an Apple TV 4K against native focus feel: UIKit's
|
||||
// indirect-touch acceleration means roughly half an item's extent of
|
||||
// reported travel already reads as "one deliberate swipe".
|
||||
const double _swipeExtentGain = 0.55;
|
||||
const double _maxSwipeThreshold = 180;
|
||||
|
||||
/// Bridges tvOS touch-surface events from Apple's iOS Remote app into the
|
||||
/// focus-tree key events Plezy already handles for D-pad navigation.
|
||||
///
|
||||
@@ -29,39 +37,22 @@ class AppleTvRemotePlayPauseAction {
|
||||
class AppleTvRemoteTouchService {
|
||||
static const String _channelName = 'flutter/gamepadtouchevent';
|
||||
static const double defaultSwipeThreshold = 180;
|
||||
static const double defaultAxisSwitchDominanceRatio = 1.5;
|
||||
static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140);
|
||||
// Device-tuned on an Apple TV 4K against native focus feel: UIKit's
|
||||
// indirect-touch acceleration means roughly half an item's extent of
|
||||
// reported travel already reads as "one deliberate swipe".
|
||||
static const double defaultSwipeExtentGain = 0.55;
|
||||
static const double defaultMinSwipeThreshold = 50;
|
||||
static const double defaultMaxSwipeThreshold = 180;
|
||||
|
||||
static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService();
|
||||
|
||||
final BasicMessageChannel<dynamic> _channel;
|
||||
final BasicMessageChannel<dynamic> _channel = const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec());
|
||||
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress;
|
||||
final VoidCallback _scheduleFrame;
|
||||
final DateTime Function() _now;
|
||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||
|
||||
/// Announces that a pointerless device produced input. Injected so tests can
|
||||
/// observe it without a widget tree; defaults to the app-wide tracker.
|
||||
final void Function() reportNonPointerInput;
|
||||
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
|
||||
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
|
||||
|
||||
/// Fallback step distance when no usable focus geometry exists.
|
||||
final double swipeThreshold;
|
||||
final double axisSwitchDominanceRatio;
|
||||
final Duration swipeRepeatInterval;
|
||||
|
||||
/// Multiplier from the focused control's extent to the pan distance for one
|
||||
/// step. Empirical; see the default constants for the device calibration.
|
||||
final double swipeExtentGain;
|
||||
final double minSwipeThreshold;
|
||||
final double maxSwipeThreshold;
|
||||
|
||||
/// Global rect of the control that prices a focus step, or null when no
|
||||
/// usable geometry exists. Injected so tests can supply fake geometry.
|
||||
@@ -78,30 +69,18 @@ class AppleTvRemoteTouchService {
|
||||
DateTime? _lastSwipeAt;
|
||||
|
||||
AppleTvRemoteTouchService({
|
||||
BasicMessageChannel<dynamic>? channel,
|
||||
void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress,
|
||||
VoidCallback? scheduleFrame,
|
||||
DateTime Function()? now,
|
||||
GamepadDuplicateInputGuard? duplicateInputGuard,
|
||||
this.reportNonPointerInput = InputModeTracker.reportNonPointerInput,
|
||||
Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow,
|
||||
this.swipeThreshold = defaultSwipeThreshold,
|
||||
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
|
||||
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
|
||||
this.swipeExtentGain = defaultSwipeExtentGain,
|
||||
this.minSwipeThreshold = defaultMinSwipeThreshold,
|
||||
this.maxSwipeThreshold = defaultMaxSwipeThreshold,
|
||||
Rect? Function()? focusedItemRect,
|
||||
}) : assert(axisSwitchDominanceRatio >= 1),
|
||||
assert(swipeExtentGain > 0),
|
||||
assert(minSwipeThreshold > 0 && minSwipeThreshold <= maxSwipeThreshold),
|
||||
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
|
||||
}) : assert(minSwipeThreshold > 0 && minSwipeThreshold <= _maxSwipeThreshold),
|
||||
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
|
||||
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
|
||||
_now = now ?? DateTime.now,
|
||||
_focusedItemRect = focusedItemRect ?? _defaultFocusedItemRect,
|
||||
_duplicateInputGuard =
|
||||
duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow);
|
||||
_duplicateInputGuard = GamepadDuplicateInputGuard(now: now);
|
||||
|
||||
Stream<AppleTvRemotePlayPauseAction> get playPauseActions => _playPauseController.stream;
|
||||
|
||||
@@ -208,7 +187,7 @@ class AppleTvRemoteTouchService {
|
||||
|
||||
final now = _now();
|
||||
final lastSwipeAt = _lastSwipeAt;
|
||||
if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) {
|
||||
if (lastSwipeAt != null && now.difference(lastSwipeAt) < _swipeRepeatInterval) {
|
||||
// Travel during the repeat cooldown never counts toward the next step:
|
||||
// re-anchor on every frame so a fast flick's deceleration tail is
|
||||
// discarded instead of banked. Without this, the first post-cooldown
|
||||
@@ -272,8 +251,8 @@ class AppleTvRemoteTouchService {
|
||||
final lastAxisTotal = _axisValue(lastAxis, totalProgressX, totalProgressY);
|
||||
final candidateSegment = _axisValue(candidate, progressX, progressY);
|
||||
final lastAxisSegment = _axisValue(lastAxis, progressX, progressY);
|
||||
if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio &&
|
||||
candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) {
|
||||
if (candidateTotal >= lastAxisTotal * _axisSwitchDominanceRatio &&
|
||||
candidateSegment >= lastAxisSegment * _axisSwitchDominanceRatio) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -292,7 +271,7 @@ class AppleTvRemoteTouchService {
|
||||
|
||||
double _thresholdForExtent(double extent) {
|
||||
if (!extent.isFinite || extent <= 0) return swipeThreshold;
|
||||
return (extent * swipeExtentGain).clamp(minSwipeThreshold, maxSwipeThreshold).toDouble();
|
||||
return (extent * _swipeExtentGain).clamp(minSwipeThreshold, _maxSwipeThreshold).toDouble();
|
||||
}
|
||||
|
||||
/// Reads the primary focus geometry, rejecting nodes whose rect cannot
|
||||
@@ -317,15 +296,13 @@ class AppleTvRemoteTouchService {
|
||||
return false;
|
||||
}
|
||||
|
||||
reportNonPointerInput();
|
||||
InputModeTracker.reportNonPointerInput();
|
||||
_scheduleFrame();
|
||||
_log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}');
|
||||
_simulateKeyPress(logicalKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
Duration get duplicateSuppressionWindow => _duplicateInputGuard.suppressionWindow;
|
||||
|
||||
void _resetTouch() {
|
||||
_touchActive = false;
|
||||
_lastSwipeAxis = null;
|
||||
|
||||
@@ -69,30 +69,8 @@ void main() {
|
||||
expect(shouldPass(isOverlaySheetOpen: true), isFalse);
|
||||
expect(shouldPass(isRouteCurrent: false), isFalse);
|
||||
expect(shouldPass(isAppleTV: false), isFalse);
|
||||
});
|
||||
|
||||
test('tvOS Menu policy transaction publishes only the settled navigation state', () {
|
||||
var desired = false;
|
||||
final published = <bool>[];
|
||||
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
|
||||
|
||||
publisher.run(() {
|
||||
desired = true;
|
||||
publisher.update();
|
||||
desired = false;
|
||||
});
|
||||
|
||||
expect(published, [false]);
|
||||
});
|
||||
|
||||
test('tvOS Menu policy publishes retained sidebar Home state immediately', () {
|
||||
final desired = true;
|
||||
final published = <bool>[];
|
||||
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
|
||||
|
||||
publisher.update();
|
||||
|
||||
expect(published, [true]);
|
||||
expect(shouldPass(isShowingProfileSelection: true), isFalse);
|
||||
expect(shouldPass(hasVisibleTabs: false), isFalse);
|
||||
});
|
||||
|
||||
test('desktop physical Escape is reserved for window fullscreen only at root Home', () {
|
||||
|
||||
Reference in New Issue
Block a user