@@ -251,7 +251,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
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<LibrariesScreen>
|
||||
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<LibrariesScreen>
|
||||
if (_visibleTabs[tabController.index] == LibraryTabType.browse) {
|
||||
(tabState as dynamic).focusChipsBar();
|
||||
} else {
|
||||
(tabState as dynamic).focusFirstItem();
|
||||
(tabState as dynamic).focusContentOrChrome();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
bool _hasLoadedData = false;
|
||||
@protected
|
||||
bool hasFocused = false;
|
||||
bool _hasFocusedChromeFallback = false;
|
||||
|
||||
// Getters for subclasses
|
||||
List<T> get items => _items;
|
||||
@@ -125,6 +126,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> 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<T, W extends BaseLibraryTab<T>> 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.
|
||||
|
||||
@@ -357,10 +357,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
|
||||
// from interfering with TabBarView page animations
|
||||
if (!InputModeTracker.isKeyboardMode(context)) return;
|
||||
if (widget.isActive && hasLoadedData && !hasFocused && loadedItems.isNotEmpty) {
|
||||
if (widget.isActive && hasLoadedData && !hasFocused) {
|
||||
hasFocused = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) focusFirstItem();
|
||||
if (mounted) focusContentOrChrome();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasFocusableContent => _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;
|
||||
|
||||
|
||||
@@ -255,7 +255,20 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
@override
|
||||
void focusFirstItem() {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
final rail = _tvBrowseRailKey.currentState;
|
||||
if (rail != null) {
|
||||
rail.requestFocus();
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final rail = _tvBrowseRailKey.currentState;
|
||||
if (rail != null) {
|
||||
rail.requestFocus();
|
||||
} else {
|
||||
focusEmptyState();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/screens/libraries/tabs/base_library_tab.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
const _library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies');
|
||||
|
||||
class _ProbeTab extends BaseLibraryTab<String> {
|
||||
const _ProbeTab({super.key, required this.loadedItems, required super.onBack, super.isActive = true})
|
||||
: super(library: _library);
|
||||
|
||||
final List<String> loadedItems;
|
||||
|
||||
@override
|
||||
State<_ProbeTab> createState() => _ProbeTabState();
|
||||
}
|
||||
|
||||
class _ProbeTabState extends BaseLibraryTabState<String, _ProbeTab> {
|
||||
int focusFirstItemCalls = 0;
|
||||
|
||||
@override
|
||||
Future<List<String>> loadData() async => widget.loadedItems;
|
||||
|
||||
@override
|
||||
Widget buildContent(List<String> 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<String> 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user