fix(library): refetch content tabs when the app resumes from a long backgrounding

Opening Plezy on a TV box resumes the resident process rather than
cold-starting it, so the Libraries grid kept showing the in-memory
content from the previous session -- hours stale -- until the user
switched libraries and back. Nothing on the resume path refreshed tab
content.

MainScreen now latches genuine backgrounding and, on a resume more than
five minutes later, refreshes LibrariesProvider and sweeps the content
tabs through each screen's in-place Refreshable.refresh() -- skipped
while offline, before startup priming, during playback, or with no
connected servers. LibrariesScreen.refresh() now refetches the selected
library's loaded tabs (the toolbar refresh action) instead of
re-selecting the saved library, which never reloaded them because tab
widgets only reload when the library's globalKey changes.

close #2043
This commit is contained in:
edde746
2026-08-20 18:19:03 +02:00
parent 21f48382bc
commit 54af977f39
4 changed files with 259 additions and 8 deletions
+12 -3
View File
@@ -469,11 +469,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
});
}
// Public method to refresh content (for normal navigation)
/// Refetch content in place (stale-resume sweep from MainScreen, #2043).
///
/// With a library on screen this refetches its instantiated tabs — the same
/// action as the toolbar refresh button. Selecting the saved library again
/// would not: the tab widgets are keyed on stable GlobalKeys and only reload
/// when the library's globalKey changes. Without a selection yet (provider
/// was empty at startup) it re-runs initialization instead.
@override
void refresh() {
// Reinitialize with current libraries
_initializeWithLibraries();
if (_selectedLibraryGlobalKey == null) {
_initializeWithLibraries();
return;
}
_refreshSelectedLibraryTabs();
}
// Refresh every loaded tab for the selected library.
+71 -1
View File
@@ -153,6 +153,49 @@ class ProfileSelectionResumeGate {
}
}
/// Latches when the app leaves the foreground and, on the next `resumed`,
/// reports whether it stayed backgrounded long enough that on-screen content
/// should be refetched (#2043).
///
/// TV launchers resume the resident process rather than cold-starting it, so
/// "opening the app" hours later otherwise shows the in-memory grids from the
/// previous session. The [staleAfter] threshold keeps short interruptions
/// (app switch, notification shade, desktop alt-tab reaching `hidden`) from
/// refetching and resetting scroll/focus state, and `inactive`-only overlays
/// never latch at all — same shape as [ProfileSelectionResumeGate].
@visibleForTesting
class ContentRefreshResumeGate {
ContentRefreshResumeGate({this.staleAfter = const Duration(minutes: 5), DateTime Function()? now})
: _now = now ?? DateTime.now;
/// Minimum time spent backgrounded before a resume triggers a refresh.
final Duration staleAfter;
final DateTime Function() _now;
DateTime? _backgroundedAt;
/// Feeds one lifecycle transition through the gate. Returns true exactly
/// once per backgrounding: on the first `resumed` after the sequence
/// reached `hidden`/`paused`/`detached` at least [staleAfter] ago. Keeps
/// the *earliest* backgrounded timestamp, so `hidden -> paused` churn while
/// backgrounded doesn't restart the clock.
bool consumeRefreshOn(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
final backgroundedAt = _backgroundedAt;
_backgroundedAt = null;
return backgroundedAt != null && _now().difference(backgroundedAt) >= staleAfter;
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_backgroundedAt ??= _now();
return false;
case AppLifecycleState.inactive:
return false;
}
}
}
@visibleForTesting
({double left, double width}) mainScreenSideNavigationContentLayout({
required double viewportWidth,
@@ -306,6 +349,10 @@ class _MainScreenState extends State<MainScreen>
/// (`inactive`, e.g. the Fire TV Alexa overlay) never prompt.
final _profileSelectionResumeGate = ProfileSelectionResumeGate();
/// Latches a genuine backgrounding and, on resume, reports whether the app
/// was gone long enough that the content tabs should refetch (#2043).
final _contentRefreshResumeGate = ContentRefreshResumeGate();
late List<Widget> _screens;
/// One [GlobalKey] per tab, so a tab's live [State] can be reached from
@@ -996,8 +1043,9 @@ class _MainScreenState extends State<MainScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Always consume: the gate latches backgrounding on every lifecycle event.
// Always consume: the gates latch backgrounding on every lifecycle event.
final resumedFromBackground = _profileSelectionResumeGate.consumePromptOn(state);
final refreshStaleContent = _contentRefreshResumeGate.consumeRefreshOn(state);
if (shouldShowProfileSelectionOnResume(
resumedFromBackground: resumedFromBackground,
isOffline: _isOffline,
@@ -1007,6 +1055,28 @@ class _MainScreenState extends State<MainScreen>
)) {
_showProfileSelectionOnResume();
}
if (refreshStaleContent) _refreshContentAfterStaleResume();
}
/// Refetch the content tabs after the app resumes from a long backgrounding.
///
/// TV launchers resume the resident process rather than cold-starting it, so
/// without this the tabs keep showing the in-memory content from the previous
/// session — the Libraries grid could sit hours stale until the user switched
/// libraries (#2043). Goes through [Refreshable.refresh], each screen's
/// non-destructive refetch: Discover refreshes Continue Watching in place,
/// Libraries refetches the selected library's loaded tabs, Search re-runs a
/// non-empty query. Skipped while playback is up — nothing content-stale is
/// on screen and the playback path must stay quiet.
void _refreshContentAfterStaleResume() {
if (_isOffline || !_startupServicesPrimed || !mounted) return;
if (VideoPlayerScreenState.activeGlobalKey != null) return;
if (!context.read<MultiServerProvider>().hasConnectedServers) return;
appLogger.d('Refreshing content tabs after stale resume');
unawaited(context.read<LibrariesProvider>().refresh());
for (final tab in _contentTabs) {
_onScreen<Refreshable>(tab, (screen) => screen.refresh());
}
}
Future<void> _showProfileSelectionOnResume() async {
@@ -3,9 +3,16 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_filter_result.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_sort.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/mixins/refreshable.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
@@ -15,12 +22,15 @@ import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../../test_helpers/library_tab_scaffold.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
@@ -38,6 +48,15 @@ const _libraryB = MediaLibrary(
kind: MediaKind.show,
serverId: 'server',
);
// Mirrors the library_browse_tab_test harness: a Jellyfin music library whose
// server has a live fake client, so the browse tab loads real (fake) pages.
const _musicLibrary = MediaLibrary(
id: 'music',
backend: MediaBackend.jellyfin,
title: 'Music',
kind: MediaKind.artist,
serverId: 'server-c',
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -117,6 +136,34 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('refresh() refetches the selected library tabs in place (#2043)', (tester) async {
final client = _PagedClient('server-c');
final preferences = _GatedPreferences({
'selected_library_key': _musicLibrary.globalKey,
'library_tab_${_musicLibrary.globalKey}': LibraryTabType.browse.name,
});
final harness = await _Harness.create(preferences, clients: [client], libraryOrder: const [_musicLibrary]);
addTearDown(harness.dispose);
final selected = <String>[];
// Paged browse content never quiesces enough for pumpAndSettle.
await harness.pump(tester, onLibrarySelected: selected.add, settle: false);
await pumpRequestFrames(tester);
final loadsBefore = client.pageRequestCount;
expect(loadsBefore, greaterThan(0));
final selectionsBefore = selected.length;
(tester.state(find.byType(LibrariesScreen)) as Refreshable).refresh();
await pumpRequestFrames(tester);
// The stale-resume sweep must reach the server again. Re-running
// initialization (the pre-#2043 refresh) re-selects the unchanged saved
// library, which never reloads its tabs, and this count stays flat.
expect(client.pageRequestCount, greaterThan(loadsBefore));
// In place: no re-selection churn.
expect(selected.length, selectionsBefore);
});
}
final class _Harness {
@@ -126,20 +173,27 @@ final class _Harness {
final HiddenLibrariesProvider hiddenLibraries;
final MultiServerProvider multiServer;
static Future<_Harness> create(_GatedPreferences preferences) async {
static Future<_Harness> create(
_GatedPreferences preferences, {
List<MediaServerClient> clients = const [],
List<MediaLibrary> libraryOrder = const [_libraryA, _libraryB],
}) async {
SharedPreferencesAsyncPlatform.instance = preferences;
await SettingsService.getInstance();
await StorageService.getInstance();
final libraries = LibrariesProvider();
await libraries.updateLibraryOrder(const [_libraryA, _libraryB]);
await libraries.updateLibraryOrder(libraryOrder);
final hiddenLibraries = HiddenLibrariesProvider();
await hiddenLibraries.ensureInitialized();
final manager = MultiServerManager();
for (final client in clients) {
manager.debugRegisterClientForTesting(client);
}
final multiServer = testMultiServerProvider(manager);
return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer);
}
Future<void> pump(WidgetTester tester, {ValueChanged<String>? onLibrarySelected}) async {
Future<void> pump(WidgetTester tester, {ValueChanged<String>? onLibrarySelected, bool settle = true}) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -160,7 +214,7 @@ final class _Harness {
),
),
);
await tester.pumpAndSettle();
if (settle) await tester.pumpAndSettle();
}
TabController controller(WidgetTester tester) {
@@ -206,3 +260,61 @@ final class _GatedPreferences extends InMemorySharedPreferencesAsync {
return result;
}
}
/// Minimal paged client so the browse tab performs real loads; every other
/// client call (e.g. the recommended tab's hub fetch) throws via
/// [noSuchMethod] and lands in that tab's caught error state.
class _PagedClient implements MediaServerClient {
_PagedClient(String serverId) : serverId = ServerId(serverId);
@override
final ServerId serverId;
var pageRequestCount = 0;
@override
String get serverName => 'Server C';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) async => const [];
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
return LibraryFilterResult.empty;
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) async {
pageRequestCount++;
return LibraryPage<MediaItem>(
items: [
testMediaItem(
id: 'artist-$pageRequestCount',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: serverId.value,
serverName: serverName,
),
],
totalCount: 1,
);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+60
View File
@@ -200,6 +200,66 @@ void main() {
});
});
group('ContentRefreshResumeGate', () {
// Injectable clock: tests advance `now` instead of sleeping.
ContentRefreshResumeGate gateAt(DateTime Function() now) =>
ContentRefreshResumeGate(staleAfter: const Duration(minutes: 5), now: now);
test('refreshes once after a backgrounding longer than the threshold (#2043)', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(hours: 18));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
// Consumed: an immediate second resume must not refresh again.
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh after a short backgrounding', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.hidden), isFalse);
now = now.add(const Duration(minutes: 4, seconds: 59));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh for overlay-style inactive -> resumed', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.inactive), isFalse);
now = now.add(const Duration(hours: 1));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh on a cold-open resume with no prior backgrounding', () {
final gate = gateAt(DateTime.now);
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('keeps the earliest backgrounded time across hidden -> paused churn', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.hidden), isFalse);
now = now.add(const Duration(minutes: 4));
// A later deeper state must not restart the clock.
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 2));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
});
test('resume resets the latch for the next backgrounding', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 1));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
// The stale clock must start from the *new* backgrounding, not the old one.
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 5));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
});
});
testWidgets('side navigation bleed animates from the previous value', (tester) async {
Widget build(double targetBleed) {
return Directionality(