fix(libraries): keep a sidebar library selection that mounts the Libraries tab

Choosing a hidden library from the sidebar on a fresh launch opened the
topmost visible library instead, and reordering the sidebar changed which
wrong library appeared. A second attempt worked.

The first visit to the Libraries tab mounts LibrariesScreen, and two
post-frame callbacks then run in registration order: MainScreen's, which
applies the requested library, and the one initState registers during that
frame's build, which picks the saved or topmost-visible default. The second
one overwrote the first — and a hidden library can be neither default, so
the selection was always lost.

Initialization now treats an existing selection as authoritative, both on
entry and after each of its awaits, so a selection arriving while it is
suspended (the refresh() stale-resume path) also survives.

close #2207
This commit is contained in:
edde746
2026-09-01 16:11:09 +02:00
parent 72d15b96a2
commit 582d419618
2 changed files with 57 additions and 2 deletions
+14 -1
View File
@@ -136,10 +136,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Initialize the screen with libraries from the provider.
/// This handles initial library selection and content loading.
///
/// An existing selection is authoritative. A sidebar library selection that
/// mounts this screen (`MainScreen._selectLibrary`) applies its target from a
/// post-frame callback registered *before* the one in [initState], so this
/// runs second and must not replace the requested library — which may be
/// hidden, and so is never the saved or topmost-visible default — with a
/// default. The re-checks cover the reverse interleaving, where a selection
/// lands while this is suspended on an await ([refresh]'s stale-resume
/// entry point).
Future<void> _initializeWithLibraries() async {
if (_selectedLibraryGlobalKey != null) return;
final librariesProvider = context.read<LibrariesProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
await hiddenLibrariesProvider.ensureInitialized();
if (!mounted || _selectedLibraryGlobalKey != null) return;
final allLibraries = librariesProvider.libraries;
if (allLibraries.isEmpty) {
@@ -151,6 +163,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
final storage = await StorageService.getInstance();
if (!mounted || _selectedLibraryGlobalKey != null) return;
final savedLibraryKey = storage.getSelectedLibraryKey();
String? libraryGlobalKeyToLoad;
@@ -165,7 +178,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
}
if (libraryGlobalKeyToLoad != null && mounted) {
if (libraryGlobalKeyToLoad != null) {
unawaited(_loadLibraryContent(libraryGlobalKeyToLoad));
}
}
@@ -18,6 +18,7 @@ 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/screens/libraries/libraries_screen.dart';
import 'package:plezy/screens/libraries/tabs/base_library_tab.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
@@ -68,6 +69,36 @@ void main() {
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('a sidebar selection at first mount survives initialization', (tester) async {
final preferences = _GatedPreferences({'selected_library_key': _libraryA.globalKey});
final harness = await _Harness.create(preferences);
addTearDown(harness.dispose);
// The requested library is hidden, so it can be neither the saved key nor
// the topmost visible default — exactly the reported repro.
await harness.hiddenLibraries.hideLibrary(_libraryB.globalKey);
final selected = <String>[];
// MainScreen._selectLibrary registers its loadLibraryByKey callback before
// the frame that mounts this screen, so it runs ahead of the post-frame
// callback initState registers during that very frame's build.
await harness.pump(
tester,
onLibrarySelected: selected.add,
onFirstPostFrame: () =>
(tester.state(find.byType(LibrariesScreen)) as LibraryLoadable).loadLibraryByKey(_libraryB.globalKey),
);
// Initialization must not follow up with the default library.
expect(selected, [_libraryB.globalKey]);
// ...and the content on screen is the requested library's.
final mountedTabs = tester
.widgetList(find.byWidgetPredicate((widget) => widget is BaseLibraryTab))
.cast<BaseLibraryTab>()
.toList();
expect(mountedTabs, isNotEmpty);
expect(mountedTabs.map((tab) => tab.library.globalKey).toSet(), {_libraryB.globalKey});
});
testWidgets('stale saved tab cannot replace the current library tab', (tester) async {
final preferences = _GatedPreferences({
'selected_library_key': _libraryB.globalKey,
@@ -193,12 +224,23 @@ final class _Harness {
return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer);
}
Future<void> pump(WidgetTester tester, {ValueChanged<String>? onLibrarySelected, bool settle = true}) async {
Future<void> pump(
WidgetTester tester, {
ValueChanged<String>? onLibrarySelected,
bool settle = true,
VoidCallback? onFirstPostFrame,
}) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// Registered before the mounting frame, so it runs ahead of the callback
// LibrariesScreen.initState adds during that frame's build.
if (onFirstPostFrame != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => onFirstPostFrame());
}
await tester.pumpWidget(
MultiProvider(
providers: [