fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles stream once it landed. Storage is what supplies profile recency, so the second view arrived re-sorted a microtask after first paint. The sliver children carried no keys, so that reorder handed each tile's Element the next profile's focus node; detaching the old node dropped primary focus onto the enclosing scope and took the D-pad highlight with it. The launch picker has no back route on tvOS, so a user who can no longer see or move the selection has nothing useful left to press. Read StorageService from the provider graph, where it is already resolved before any route exists, so the stream is built once and the first painted frame is already recency-sorted. Key the tiles and add findChildIndexCallback so a later re-sort from a refreshed profile source moves a tile instead of destroying it: without the lookup the sliver re-inflates the tile, which keeps primary focus but resets FocusableWrapper's chrome to unfocused. close #1792
This commit is contained in:
@@ -55,32 +55,27 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
bool _focusRequested = false;
|
||||
bool _switching = false;
|
||||
Stream<ProfilesView>? _viewStream;
|
||||
StorageService? _viewStreamStorage;
|
||||
StorageService? _storage;
|
||||
Future<StorageService>? _storageFuture;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_ensureViewStream();
|
||||
if (_storage == null) {
|
||||
unawaited(
|
||||
(_storageFuture ??= StorageService.getInstance()).then((s) {
|
||||
setStateIfMounted(() => _storage = s);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Built exactly once. Resolving [StorageService] asynchronously here used
|
||||
/// to rebuild the stream a microtask after first paint, and because storage
|
||||
/// is what supplies profile recency the second view arrived re-sorted — the
|
||||
/// reorder then rebound the tiles' focus nodes and dropped the D-pad
|
||||
/// highlight onto the enclosing scope (#1792). Storage is already resolved
|
||||
/// before any route exists, so read it from the provider graph instead.
|
||||
void _ensureViewStream() {
|
||||
if (_viewStream != null && identical(_viewStreamStorage, _storage)) return;
|
||||
_viewStreamStorage = _storage;
|
||||
if (_viewStream != null) return;
|
||||
_viewStream = watchProfilesView(
|
||||
profiles: context.read<ProfileRegistry>(),
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
plexHome: context.read<PlexHomeService>(),
|
||||
storage: _storage,
|
||||
storage: context.read<StorageService>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,71 +215,87 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
}
|
||||
|
||||
SliverList _profileList(List<Profile> profiles, ProfilesView view, String? activeId, {required bool autofocusFirst}) {
|
||||
// The tile keys and `findChildIndexCallback` below are one mechanism, not
|
||||
// two independent safeguards. A refreshed profile source can re-sort this
|
||||
// list after first paint; the key stops the sliver handing a tile's
|
||||
// Element the next profile's focus node, and the lookup lets it map that
|
||||
// key to its new index. Without the lookup the tile is destroyed and
|
||||
// re-inflated instead of moved, which keeps primary focus but resets
|
||||
// FocusableWrapper's chrome — a focused tile with no highlight (#1792).
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final profile = profiles[index];
|
||||
final isActive = profile.id == activeId;
|
||||
final tokensRef = tokens(context);
|
||||
final tileRadii = groupItemRadii(context, index, profiles.length);
|
||||
final isFirstSelectable = autofocusFirst && index == 0;
|
||||
final profileFocusNode = _profileFocusNode(profile);
|
||||
final menuFocusNode = _profileMenuFocusNode(profile);
|
||||
final menuKey = _profileMenuKey(profile);
|
||||
// All tile actions are disabled while a switch is binding: the
|
||||
// overlay's barrier blocks pointers but not DPAD key events, and a
|
||||
// Manage/Delete flow racing the in-flight switch corrupts state
|
||||
// (e.g. a delete confirmation left open when the switch settles).
|
||||
final actionsEnabled = !widget.requireSelection && !_switching;
|
||||
final onManage = actionsEnabled ? () => _manageProfile(profile) : null;
|
||||
final onDelete = profile.isLocal && actionsEnabled ? () => _deleteProfile(profile) : null;
|
||||
final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && actionsEnabled
|
||||
? () => _signOutPlexAccount(profile)
|
||||
: null;
|
||||
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final profile = profiles[index];
|
||||
final isActive = profile.id == activeId;
|
||||
final tokensRef = tokens(context);
|
||||
final tileRadii = groupItemRadii(context, index, profiles.length);
|
||||
final isFirstSelectable = autofocusFirst && index == 0;
|
||||
final profileFocusNode = _profileFocusNode(profile);
|
||||
final menuFocusNode = _profileMenuFocusNode(profile);
|
||||
final menuKey = _profileMenuKey(profile);
|
||||
// All tile actions are disabled while a switch is binding: the
|
||||
// overlay's barrier blocks pointers but not DPAD key events, and a
|
||||
// Manage/Delete flow racing the in-flight switch corrupts state
|
||||
// (e.g. a delete confirmation left open when the switch settles).
|
||||
final actionsEnabled = !widget.requireSelection && !_switching;
|
||||
final onManage = actionsEnabled ? () => _manageProfile(profile) : null;
|
||||
final onDelete = profile.isLocal && actionsEnabled ? () => _deleteProfile(profile) : null;
|
||||
final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && actionsEnabled
|
||||
? () => _signOutPlexAccount(profile)
|
||||
: null;
|
||||
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
|
||||
|
||||
if (isFirstSelectable && !_focusRequested) {
|
||||
_focusRequested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) profileFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
if (isFirstSelectable && !_focusRequested) {
|
||||
_focusRequested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) profileFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0),
|
||||
child: FocusableWrapper(
|
||||
autofocus: isFirstSelectable,
|
||||
focusNode: profileFocusNode,
|
||||
disableScale: true,
|
||||
borderRadii: tileRadii,
|
||||
enableLongPress: hasMenu,
|
||||
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||
onNavigateRight: hasMenu ? () => menuFocusNode.requestFocus() : null,
|
||||
onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: tileRadii),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _ProfileTile(
|
||||
borderRadius: tileRadii,
|
||||
profile: profile,
|
||||
avatarUrl: view.avatarUrlByProfile[profile.id],
|
||||
isActive: isActive && !widget.requireSelection,
|
||||
chips: _chipsFor(profile, view),
|
||||
onTap: () => _switchTo(profile),
|
||||
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||
// Manage available for any profile — adding/removing
|
||||
// borrowed connections is supported on plex_home too. Delete
|
||||
// stays local-only (Plex Home users are owned by Plex).
|
||||
onManage: onManage,
|
||||
onDelete: onDelete,
|
||||
onSignOut: onSignOut,
|
||||
menuFocusNode: menuFocusNode,
|
||||
menuKey: menuKey,
|
||||
onMenuNavigateLeft: () => profileFocusNode.requestFocus(),
|
||||
return Padding(
|
||||
key: ValueKey(profile.id),
|
||||
padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0),
|
||||
child: FocusableWrapper(
|
||||
autofocus: isFirstSelectable,
|
||||
focusNode: profileFocusNode,
|
||||
disableScale: true,
|
||||
borderRadii: tileRadii,
|
||||
enableLongPress: hasMenu,
|
||||
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||
onNavigateRight: hasMenu ? () => menuFocusNode.requestFocus() : null,
|
||||
onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: tileRadii),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _ProfileTile(
|
||||
borderRadius: tileRadii,
|
||||
profile: profile,
|
||||
avatarUrl: view.avatarUrlByProfile[profile.id],
|
||||
isActive: isActive && !widget.requireSelection,
|
||||
chips: _chipsFor(profile, view),
|
||||
onTap: () => _switchTo(profile),
|
||||
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||
// Manage available for any profile — adding/removing
|
||||
// borrowed connections is supported on plex_home too. Delete
|
||||
// stays local-only (Plex Home users are owned by Plex).
|
||||
onManage: onManage,
|
||||
onDelete: onDelete,
|
||||
onSignOut: onSignOut,
|
||||
menuFocusNode: menuFocusNode,
|
||||
menuKey: menuKey,
|
||||
onMenuNavigateLeft: () => profileFocusNode.requestFocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: profiles.length),
|
||||
);
|
||||
},
|
||||
childCount: profiles.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final id = (key as ValueKey<String>).value;
|
||||
final index = profiles.indexWhere((profile) => profile.id == id);
|
||||
return index < 0 ? null : index;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user