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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user