fix: stabilize hidden library focus traversal

This commit is contained in:
edde746
2026-05-02 10:03:36 +02:00
parent 244b291255
commit 810f1e5da5
2 changed files with 326 additions and 82 deletions
+179 -82
View File
@@ -22,6 +22,28 @@ import '../theme/mono_tokens.dart';
import '../widgets/backend_badge.dart';
import '../i18n/strings.g.dart';
enum _LibraryNavSection { visible, hidden }
sealed class _LibraryNavRow {
final _LibraryNavSection section;
const _LibraryNavRow({required this.section});
}
final class _LibraryServerHeaderRow extends _LibraryNavRow {
final String serverId;
final String serverName;
const _LibraryServerHeaderRow({required super.section, required this.serverId, required this.serverName});
}
final class _LibraryItemRow extends _LibraryNavRow {
final MediaLibrary library;
final bool showServerName;
const _LibraryItemRow({required super.section, required this.library, this.showServerName = false});
}
/// Reusable navigation rail item widget that handles focus, selection, and interaction
class NavigationRailItem extends StatelessWidget {
final IconData icon;
@@ -185,10 +207,11 @@ class SideNavigationRailState extends State<SideNavigationRail> {
static const _kReconnect = 'reconnect';
static const _kFullscreen = 'fullscreen';
static const _kHiddenLibraries = 'hiddenLibraries';
static const _kServerHeaderPrefix = 'serverHeader_';
static const _kServerHeaderPrefix = 'serverHeader';
static const _kLibraryItemPrefix = 'library';
bool _hiddenLibrariesExpanded = false;
final Set<String> _collapsedServerIds = {};
final Set<String> _collapsedServerGroupKeys = {};
// Unified focus state tracker for all nav items (main + libraries)
late final FocusMemoryTracker _focusTracker;
@@ -261,8 +284,28 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_focusTracker.restoreFocus(fallbackKey: _kHome);
}
/// Build the set of valid focus keys (main nav + current libraries + server headers)
Set<String> _buildValidFocusKeys(List<MediaLibrary> libraries, Set<String> serverIds) {
String _serverHeaderFocusKey(_LibraryNavSection section, String serverId) =>
'$_kServerHeaderPrefix:${section.name}:$serverId';
String _libraryItemFocusKey(_LibraryNavSection section, MediaLibrary library) =>
'$_kLibraryItemPrefix:${section.name}:${library.globalKey}';
String _serverGroupStateKey(_LibraryNavSection section, String serverId) => '${section.name}:$serverId';
String _focusKeyForLibraryRow(_LibraryNavRow row) => switch (row) {
_LibraryServerHeaderRow(:final section, :final serverId) => _serverHeaderFocusKey(section, serverId),
_LibraryItemRow(:final section, :final library) => _libraryItemFocusKey(section, library),
};
Iterable<String> _focusKeysForLibraryRows(List<_LibraryNavRow> rows) => rows.map(_focusKeyForLibraryRow);
/// Build the set of valid focus keys (main nav + currently rendered library rows).
Set<String> _buildValidFocusKeys({
required List<_LibraryNavRow> visibleRows,
required List<_LibraryNavRow> hiddenRows,
required bool hasHiddenLibraries,
required bool hasLiveTv,
}) {
return {
_kHome,
_kLibraries,
@@ -270,42 +313,74 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_kDownloads,
_kSettings,
_kReconnect,
_kHiddenLibraries,
if (hasHiddenLibraries) _kHiddenLibraries,
if (_showFullscreenToggle) _kFullscreen,
'liveTv',
...libraries.map((lib) => lib.globalKey),
...serverIds.map((id) => _kServerHeaderPrefix + id),
if (hasLiveTv) 'liveTv',
..._focusKeysForLibraryRows(visibleRows),
if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows),
};
}
/// Build the visual order of library items inside the libraries body. Must
/// mirror what `_buildLibraryGroupedColumn` actually renders so D-pad
/// navigation lands on the visually next item.
List<String> _buildLibraryBodyOrder(List<MediaLibrary> libs, {required bool showServerHeaders}) {
/// Build rendered rows inside one library section. This is the single source
/// of truth for both widget rendering and D-pad focus ordering.
List<_LibraryNavRow> _buildLibraryRows(
List<MediaLibrary> libs, {
required _LibraryNavSection section,
required bool showServerHeaders,
}) {
if (!showServerHeaders) {
return libs.map((lib) => lib.globalKey).toList();
final nonUniqueNames = _getNonUniqueLibraryNames(libs);
return libs.map((lib) {
return _LibraryItemRow(
section: section,
library: lib,
showServerName: nonUniqueNames.contains(lib.title) && lib.serverName != null,
);
}).toList();
}
final grouped = groupLibrariesByFirstAppearance(libs);
final result = <String>[];
final result = <_LibraryNavRow>[];
for (final serverKey in grouped.serverOrder) {
final bucket = grouped.byServer[serverKey]!;
if (serverKey.isNotEmpty) {
result.add(_kServerHeaderPrefix + serverKey);
result.add(
_LibraryServerHeaderRow(
section: section,
serverId: serverKey,
serverName: bucket.first.serverName ?? serverKey,
),
);
}
if (serverKey.isEmpty || !_collapsedServerIds.contains(serverKey)) {
for (final lib in grouped.byServer[serverKey]!) {
result.add(lib.globalKey);
if (serverKey.isEmpty || !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverKey))) {
for (final lib in bucket) {
result.add(_LibraryItemRow(section: section, library: lib));
}
}
}
return result;
}
/// Ordered list of focusable keys matching visual top-to-bottom order.
List<String> _buildFocusOrder(
Set<String> _buildServerGroupStateKeys(
List<MediaLibrary> visibleLibraries,
List<MediaLibrary> hiddenLibraries, {
required bool hasLiveTv,
required bool showServerHeaders,
}) {
if (!showServerHeaders) return {};
return {
for (final lib in visibleLibraries)
if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.visible, lib.serverId!),
for (final lib in hiddenLibraries)
if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.hidden, lib.serverId!),
};
}
/// Ordered list of focusable keys matching visual top-to-bottom order.
List<String> _buildFocusOrder(
List<_LibraryNavRow> visibleRows,
List<_LibraryNavRow> hiddenRows, {
required bool hasHiddenLibraries,
required bool hasLiveTv,
}) {
return [
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
@@ -313,11 +388,10 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_kHome,
_kLibraries,
if (_librariesExpanded) ...[
..._buildLibraryBodyOrder(visibleLibraries, showServerHeaders: showServerHeaders),
if (hiddenLibraries.isNotEmpty) ...[
..._focusKeysForLibraryRows(visibleRows),
if (hasHiddenLibraries) ...[
_kHiddenLibraries,
if (_hiddenLibrariesExpanded)
..._buildLibraryBodyOrder(hiddenLibraries, showServerHeaders: showServerHeaders),
if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows),
],
],
if (hasLiveTv) 'liveTv',
@@ -329,6 +403,18 @@ class SideNavigationRailState extends State<SideNavigationRail> {
];
}
void _debugAssertUniqueFocusOrder(List<String> focusOrder) {
assert(() {
final seen = <String>{};
for (final key in focusOrder) {
if (!seen.add(key)) {
throw FlutterError('SideNavigationRail focus order contains duplicate key: $key');
}
}
return true;
}());
}
/// Handle D-pad UP/DOWN by explicitly moving focus to the next/previous item.
KeyEventResult _handleVerticalNavigation(FocusNode _, KeyEvent event, List<String> focusOrder) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
@@ -426,9 +512,6 @@ class SideNavigationRailState extends State<SideNavigationRail> {
}
}
_focusTracker.pruneExcept(_buildValidFocusKeys(allLibraries, serverIds));
_collapsedServerIds.retainAll(serverIds);
final isCollapsed = !_shouldExpand;
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
@@ -443,12 +526,34 @@ class SideNavigationRailState extends State<SideNavigationRail> {
// Server grouping: only when multi-server AND the user-facing toggle is on.
final groupByServerSetting = SettingsService.instanceOrNull!.read(SettingsService.groupLibrariesByServer);
final showServerHeaders = serverIds.length > 1 && groupByServerSetting;
final focusOrder = _buildFocusOrder(
_collapsedServerGroupKeys.retainAll(
_buildServerGroupStateKeys(visibleLibraries, hiddenLibraries, showServerHeaders: showServerHeaders),
);
final visibleRows = _buildLibraryRows(
visibleLibraries,
hiddenLibraries,
hasLiveTv: hasLiveTv,
section: _LibraryNavSection.visible,
showServerHeaders: showServerHeaders,
);
final hiddenRows = _buildLibraryRows(
hiddenLibraries,
section: _LibraryNavSection.hidden,
showServerHeaders: showServerHeaders,
);
_focusTracker.pruneExcept(
_buildValidFocusKeys(
visibleRows: visibleRows,
hiddenRows: hiddenRows,
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
hasLiveTv: hasLiveTv,
),
);
final focusOrder = _buildFocusOrder(
visibleRows,
hiddenRows,
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
hasLiveTv: hasLiveTv,
);
_debugAssertUniqueFocusOrder(focusOrder);
return TapRegion(
onTapOutside: (_) {
if (_isTouchExpanded) {
@@ -508,11 +613,11 @@ class SideNavigationRailState extends State<SideNavigationRail> {
// Libraries section
_buildLibrariesSection(
visibleLibraries,
hiddenLibraries,
visibleRows,
hiddenRows,
hiddenLibraries.length,
t,
isCollapsed: isCollapsed,
showServerHeaders: showServerHeaders,
),
const SizedBox(height: 8),
@@ -679,17 +784,17 @@ class SideNavigationRailState extends State<SideNavigationRail> {
}
Widget _buildLibrariesSection(
List<MediaLibrary> visibleLibraries,
List<MediaLibrary> hiddenLibraries,
List<_LibraryNavRow> visibleRows,
List<_LibraryNavRow> hiddenRows,
int hiddenLibraryCount,
dynamic t, {
bool isCollapsed = false,
bool showServerHeaders = false,
}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;
final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null;
final isLibrariesFocused = _focusTracker.isFocused(_kLibraries);
final allEmpty = visibleLibraries.isEmpty && hiddenLibraries.isEmpty;
final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -822,12 +927,10 @@ class SideNavigationRailState extends State<SideNavigationRail> {
),
)
else ...[
if (visibleLibraries.isNotEmpty)
_buildLibraryGroupedColumn(visibleLibraries, t, showServerHeaders: showServerHeaders),
if (hiddenLibraries.isNotEmpty) ...[
_buildHiddenLibrariesHeader(hiddenLibraries.length, t),
if (_hiddenLibrariesExpanded)
_buildLibraryGroupedColumn(hiddenLibraries, t, showServerHeaders: showServerHeaders),
if (visibleRows.isNotEmpty) _buildLibraryGroupedColumn(visibleRows, t),
if (hiddenLibraryCount > 0) ...[
_buildHiddenLibrariesHeader(hiddenLibraryCount, t),
if (_hiddenLibrariesExpanded) _buildLibraryGroupedColumn(hiddenRows, t),
],
],
],
@@ -847,59 +950,52 @@ class SideNavigationRailState extends State<SideNavigationRail> {
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
}
Widget _buildLibraryGroupedColumn(List<MediaLibrary> libraries, dynamic t, {required bool showServerHeaders}) {
if (!showServerHeaders) {
final nonUniqueNames = _getNonUniqueLibraryNames(libraries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: libraries.map((library) {
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
return _buildLibraryItem(library, t, showServerName: showServerName);
}).toList(),
);
}
final grouped = groupLibrariesByFirstAppearance(libraries);
final children = <Widget>[];
for (final serverKey in grouped.serverOrder) {
final bucket = grouped.byServer[serverKey]!;
// serverKey is '' when serverId is null — skip the header in that case.
if (serverKey.isNotEmpty) {
final serverName = bucket.first.serverName ?? serverKey;
children.add(_buildServerHeader(serverKey, serverName, t));
}
if (serverKey.isEmpty || !_collapsedServerIds.contains(serverKey)) {
for (final library in bucket) {
children.add(_buildLibraryItem(library, t, showServerName: false));
}
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: children);
Widget _buildLibraryGroupedColumn(List<_LibraryNavRow> rows, dynamic t) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows.map((row) {
return switch (row) {
_LibraryServerHeaderRow(:final section, :final serverId, :final serverName) => _buildServerHeader(
section,
serverId,
serverName,
t,
),
_LibraryItemRow(:final section, :final library, :final showServerName) => _buildLibraryItem(
section,
library,
t,
showServerName: showServerName,
),
};
}).toList(),
);
}
Widget _buildServerHeader(String serverId, String serverName, dynamic t) {
Widget _buildServerHeader(_LibraryNavSection section, String serverId, String serverName, dynamic t) {
// Resolve backend per server so the badge matches the brand. Falls back
// to the generic `dns` icon if the client isn't registered yet (rare —
// can happen during a profile switch before the manager rehydrates).
final backend = context.read<MultiServerProvider>().serverManager.getClient(serverId)?.backend;
return _buildCollapsibleHeader(
focusKey: _kServerHeaderPrefix + serverId,
focusKey: _serverHeaderFocusKey(section, serverId),
icon: Symbols.dns_rounded,
iconSize: 14,
leading: backend == null ? null : BackendBadge(backend: backend, size: 14, color: t.textMuted),
label: serverName,
labelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.4, color: t.textMuted),
verticalPadding: 6,
isExpanded: !_collapsedServerIds.contains(serverId),
onToggle: () => _toggleServerCollapse(serverId),
isExpanded: !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverId)),
onToggle: () => _toggleServerCollapse(section, serverId),
t: t,
);
}
void _toggleServerCollapse(String serverId) {
void _toggleServerCollapse(_LibraryNavSection section, String serverId) {
final groupKey = _serverGroupStateKey(section, serverId);
setState(() {
if (!_collapsedServerIds.add(serverId)) {
_collapsedServerIds.remove(serverId);
if (!_collapsedServerGroupKeys.add(groupKey)) {
_collapsedServerGroupKeys.remove(groupKey);
}
});
}
@@ -991,11 +1087,12 @@ class SideNavigationRailState extends State<SideNavigationRail> {
);
}
Widget _buildLibraryItem(MediaLibrary library, dynamic t, {bool showServerName = false}) {
Widget _buildLibraryItem(_LibraryNavSection section, MediaLibrary library, dynamic t, {bool showServerName = false}) {
final isSelected =
widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey;
final isFocused = _focusTracker.isFocused(library.globalKey);
final focusNode = _focusTracker.get(library.globalKey);
final focusKey = _libraryItemFocusKey(section, library);
final isFocused = _focusTracker.isFocused(focusKey);
final focusNode = _focusTracker.get(focusKey);
return Padding(
padding: const EdgeInsets.only(left: 12),
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/navigation/navigation_tabs.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
const _testTokens = MonoTokens(
radiusSm: 8,
radiusMd: 12,
space: 8,
fast: Duration(milliseconds: 1),
normal: Duration(milliseconds: 1),
slow: Duration(milliseconds: 1),
bg: Colors.black,
surface: Colors.black,
outline: Colors.white24,
text: Colors.white,
textMuted: Colors.white70,
splashFactory: NoSplash.splashFactory,
);
MediaLibrary _library({
required String id,
required String title,
required String serverId,
required String serverName,
}) {
return MediaLibrary(
id: id,
backend: MediaBackend.plex,
title: title,
kind: MediaKind.movie,
serverId: serverId,
serverName: serverName,
);
}
Future<void> _press(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyEvent(key);
await tester.pumpAndSettle();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async {
await SettingsService.getInstance();
final visibleServerALibrary = _library(
id: '1',
title: 'Visible Server A',
serverId: 'server-a',
serverName: 'Server A',
);
final hiddenServerALibrary = _library(
id: '2',
title: 'Hidden Server A',
serverId: 'server-a',
serverName: 'Server A',
);
final visibleServerBLibrary = _library(
id: '1',
title: 'Visible Server B',
serverId: 'server-b',
serverName: 'Server B',
);
final librariesProvider = LibrariesProvider();
await librariesProvider.updateLibraryOrder([visibleServerALibrary, hiddenServerALibrary, visibleServerBLibrary]);
addTearDown(librariesProvider.dispose);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
await hiddenLibrariesProvider.ensureInitialized();
await hiddenLibrariesProvider.hideLibrary(hiddenServerALibrary.globalKey);
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
addTearDown(multiServerProvider.dispose);
final sideNavKey = GlobalKey<SideNavigationRailState>();
var selectedLibraryKey = '';
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
],
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SideNavigationRail(
key: sideNavKey,
selectedTab: NavigationTabId.discover,
isSidebarFocused: true,
alwaysExpanded: true,
onDestinationSelected: (_) {},
onLibrarySelected: (key) => selectedLibraryKey = key,
),
),
),
),
),
);
await tester.pumpAndSettle();
sideNavKey.currentState!.focusActiveItem();
await tester.pumpAndSettle();
// Home -> Libraries -> Server A header -> visible A -> Server B header -> visible B -> Hidden Libraries.
for (var i = 0; i < 6; i++) {
await _press(tester, LogicalKeyboardKey.arrowDown);
}
await _press(tester, LogicalKeyboardKey.enter);
// Hidden Libraries -> hidden Server A header -> hidden Server A library.
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.enter);
expect(selectedLibraryKey, hiddenServerALibrary.globalKey);
});
}