From cd6716df474876daabe67da43ebd23a4a40a991a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:47:04 +0200 Subject: [PATCH] perf(tv): trim media card focus semantics --- lib/focus/focusable_wrapper.dart | 8 ++++ lib/widgets/focusable_media_card.dart | 4 ++ test/widgets/media_card_full_card_test.dart | 49 +++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index cf58b497..606f7c41 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -199,6 +199,12 @@ class FocusableWrapper extends StatefulWidget { /// that would compete with this wrapper's focus handling. final bool descendantsAreFocusable; + /// Whether the [Focus] node contributes focusable/focused semantics. + /// + /// Keep this enabled unless an equivalent child semantic action remains and + /// accessibility navigation is known to be inactive. + final bool includeFocusSemantics; + const FocusableWrapper({ super.key, required this.child, @@ -232,6 +238,7 @@ class FocusableWrapper extends StatefulWidget { this.useFocusGlow = false, this.delegateFocusBorder = false, this.descendantsAreFocusable = true, + this.includeFocusSemantics = true, }); @override @@ -587,6 +594,7 @@ class _FocusableWrapperState extends State with SingleTickerPr Widget result = Focus( focusNode: _focusNode, autofocus: widget.autofocus, + includeSemantics: widget.includeFocusSemantics, descendantsAreFocusable: widget.descendantsAreFocusable, onFocusChange: _handleFocusChange, onKeyEvent: _handleKeyEvent, diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 1bb72c3b..1d32a343 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -118,6 +118,10 @@ class _FocusableMediaCardState extends State { Widget build(BuildContext context) { return FocusableWrapper( focusNode: widget.focusNode, + // The MediaCard already exposes the complete static button semantics. + // Avoid invalidating a dense TV grid's semantics tree on every D-pad + // focus change unless an accessibility service needs focused state. + includeFocusSemantics: !PlatformDetector.isTV() || MediaQuery.accessibleNavigationOf(context), onSelect: () => _mediaCardKey.currentState?.handleTap(), onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), onNavigateUp: widget.onNavigateUp, diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index 906a84f4..98760537 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -1,6 +1,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focus_glow_overlay.dart'; import 'package:plezy/focus/focus_theme.dart'; @@ -316,6 +317,54 @@ void main() { semantics.dispose(); }); + testWidgets('TV cards keep focus semantics only for accessible navigation', (tester) async { + final semantics = tester.ensureSemantics(); + TvDetectionService.debugSetAppleTVOverride(true); + final focusNode = FocusNode(debugLabel: 'semantic_card'); + addTearDown(focusNode.dispose); + final item = testMediaItem(id: 'focus_semantic_movie', kind: MediaKind.movie, title: 'Focus Semantic Movie'); + + Widget card({required bool accessibleNavigation}) => _TestApp( + child: MediaQuery( + data: MediaQueryData(accessibleNavigation: accessibleNavigation), + child: SizedBox( + width: 200, + height: 330, + child: FocusableMediaCard(item: item, forceGridMode: true, focusNode: focusNode, isOffline: true), + ), + ), + ); + bool hasFocusedSemantics() { + final nodes = []; + void collect(SemanticsNode node) { + nodes.add(node); + node.visitChildren((child) { + collect(child); + return true; + }); + } + + collect(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!); + return nodes.any((node) => node.getSemanticsData().flagsCollection.isFocused == ui.Tristate.isTrue); + } + + await tester.pumpWidget(card(accessibleNavigation: false)); + focusNode.requestFocus(); + await tester.pump(); + + final cardData = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(item))).getSemanticsData(); + expect(cardData.flagsCollection.isButton, isTrue); + expect(cardData.hasAction(ui.SemanticsAction.tap), isTrue); + expect(hasFocusedSemantics(), isFalse); + + await tester.pumpWidget(card(accessibleNavigation: true)); + focusNode.requestFocus(); + await tester.pump(); + + expect(hasFocusedSemantics(), isTrue); + semantics.dispose(); + }); + testWidgets('TV cards collapse pointer-only detail semantics without a screen reader', (tester) async { final semantics = tester.ensureSemantics(); TvDetectionService.debugSetAppleTVOverride(true);