diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 80d5271df..698ca537b 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -469,11 +469,20 @@ class _LibrariesScreenState extends State }); } - // 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. diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e17e2146e..e29873f7b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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 /// (`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 _screens; /// One [GlobalKey] per tab, so a tab's live [State] can be reached from @@ -996,8 +1043,9 @@ class _MainScreenState extends State @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 )) { _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().hasConnectedServers) return; + appLogger.d('Refreshing content tabs after stale resume'); + unawaited(context.read().refresh()); + for (final tab in _contentTabs) { + _onScreen(tab, (screen) => screen.refresh()); + } } Future _showProfileSelectionOnResume() async { diff --git a/test/screens/libraries/libraries_screen_test.dart b/test/screens/libraries/libraries_screen_test.dart index 8c1da0acc..1f1c4c07e 100644 --- a/test/screens/libraries/libraries_screen_test.dart +++ b/test/screens/libraries/libraries_screen_test.dart @@ -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 = []; + + // 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 clients = const [], + List 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 pump(WidgetTester tester, {ValueChanged? onLibrarySelected}) async { + Future pump(WidgetTester tester, {ValueChanged? 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> fetchSortOptions(String libraryId, {String? libraryType}) async => const []; + + @override + Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async { + return LibraryFilterResult.empty; + } + + @override + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }) async { + pageRequestCount++; + return LibraryPage( + 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); +} diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index f1ed6fdff..b886ca42e 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -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(