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:
edde746
2026-08-17 19:02:20 +02:00
parent 3703ac47c3
commit a910aa95cb
3 changed files with 26 additions and 110 deletions
+8 -47
View File
@@ -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,
+16 -39
View File
@@ -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;
+2 -24
View File
@@ -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', () {