From 656cf1158bdfd052ee56b583544f040237c9f49a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:05:02 +0200 Subject: [PATCH] fix(libraries): restore tvOS empty tab focus close #1330 --- lib/screens/libraries/libraries_screen.dart | 6 +- .../libraries/tabs/base_library_tab.dart | 37 +++++++- .../libraries/tabs/library_browse_tab.dart | 16 +++- .../tabs/library_recommended_tab.dart | 15 ++- .../base_library_tab_focus_test.dart | 91 +++++++++++++++++++ 5 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 test/screens/libraries/base_library_tab_focus_test.dart diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index dcd44378..78b2b496 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -251,7 +251,7 @@ class _LibrariesScreenState extends State final tabState = _getTabState(tabController.index); if (tabState != null) { - (tabState as dynamic).focusFirstItem(); + (tabState as dynamic).focusContentOrChrome(); } else { // State not available yet, retry after another frame WidgetsBinding.instance.addPostFrameCallback((_) { @@ -266,7 +266,7 @@ class _LibrariesScreenState extends State void _focusCurrentTabImmediate() { final tabState = _getTabState(tabController.index); if (tabState != null) { - (tabState as dynamic).focusFirstItem(); + (tabState as dynamic).focusContentOrChrome(); } } @@ -296,7 +296,7 @@ class _LibrariesScreenState extends State if (_visibleTabs[tabController.index] == LibraryTabType.browse) { (tabState as dynamic).focusChipsBar(); } else { - (tabState as dynamic).focusFirstItem(); + (tabState as dynamic).focusContentOrChrome(); } } }); diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 1565e36e..797b7a91 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -79,6 +79,7 @@ abstract class BaseLibraryTabState> extends State bool _hasLoadedData = false; @protected bool hasFocused = false; + bool _hasFocusedChromeFallback = false; // Getters for subclasses List get items => _items; @@ -125,6 +126,7 @@ abstract class BaseLibraryTabState> extends State if (oldWidget.library.globalKey != widget.library.globalKey) { // Reset focus state for new library hasFocused = false; + _hasFocusedChromeFallback = false; _hasLoadedData = false; // Immediately clear stale data before async load _items = []; @@ -169,14 +171,47 @@ abstract class BaseLibraryTabState> extends State // from interfering with TabBarView page animations if (!InputModeTracker.isKeyboardMode(context)) return; - if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) { + if (!widget.isActive || !_hasLoadedData) return; + + if (hasFocusableContent) { + _hasFocusedChromeFallback = false; + if (hasFocused) return; hasFocused = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { focusFirstItem(); } }); + return; } + + if (!_hasFocusedChromeFallback) { + _hasFocusedChromeFallback = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + focusEmptyState(); + } + }); + } + } + + /// Whether [focusFirstItem] has a real content target to focus. + @protected + bool get hasFocusableContent => _items.isNotEmpty; + + /// Focus content when available, otherwise return to the library chrome. + void focusContentOrChrome() { + if (hasFocusableContent) { + focusFirstItem(); + } else { + focusEmptyState(); + } + } + + /// Fallback for empty/error states, where content has no focusable child. + @protected + void focusEmptyState() { + widget.onBack?.call(); } /// Focus the first item in the tab. Subclasses should override this. diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index ffa943ea..0f9f3b2c 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -357,10 +357,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _selectedGrouping == 'folders' || loadedItems.isNotEmpty; + + @override + void focusContentOrChrome() { + if (hasFocusableContent) { + focusFirstItem(); + } else { + focusChipsBar(); + } + } + /// Height of the chips bar (padding + chip + padding) static const double _chipsBarHeight = 32.0; diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 90c076ce..5ddae040 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -255,7 +255,20 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState { + const _ProbeTab({super.key, required this.loadedItems, required super.onBack, super.isActive = true}) + : super(library: _library); + + final List loadedItems; + + @override + State<_ProbeTab> createState() => _ProbeTabState(); +} + +class _ProbeTabState extends BaseLibraryTabState { + int focusFirstItemCalls = 0; + + @override + Future> loadData() async => widget.loadedItems; + + @override + Widget buildContent(List items) => const SizedBox.shrink(); + + @override + IconData get emptyIcon => Icons.inbox_rounded; + + @override + String get emptyMessage => 'Empty'; + + @override + String get errorContext => 'probe'; + + @override + void focusFirstItem() { + focusFirstItemCalls++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + Future<_ProbeTabState> pumpProbe( + WidgetTester tester, { + required List loadedItems, + required VoidCallback onBack, + }) async { + final key = GlobalKey<_ProbeTabState>(); + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp( + home: _ProbeTab(key: key, loadedItems: loadedItems, onBack: onBack), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return key.currentState!; + } + + testWidgets('empty active tab focuses library chrome fallback', (tester) async { + var fallbackCalls = 0; + + final state = await pumpProbe(tester, loadedItems: const [], onBack: () => fallbackCalls++); + + expect(fallbackCalls, 1); + expect(state.focusFirstItemCalls, 0); + }); + + testWidgets('non-empty active tab focuses first item', (tester) async { + var fallbackCalls = 0; + + final state = await pumpProbe(tester, loadedItems: const ['item'], onBack: () => fallbackCalls++); + + expect(fallbackCalls, 0); + expect(state.focusFirstItemCalls, 1); + }); +}