fix(library): replay a push dropped under a route once the tab is visible again

A library tab sitting under an opaque route (detail page, player) has
TickerMode disabled, so a server push arriving then was dropped while the
provider still marked the library stale; nothing observed the route pop, and
the tab stayed stale until the user switched tabs.

The tab now listens to the same effective-visibility notifier its suppression
predicate reads and, when an active stale tab becomes visible, wakes the
existing paced in-place refresh — no clearing reload, so scroll and focus
stay put. Main-tab activation still consumes the epoch first, so no second
pass is scheduled.
This commit is contained in:
edde746
2026-09-06 13:17:40 +02:00
parent deeec6810d
commit b795ef1604
2 changed files with 187 additions and 6 deletions
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../focus/input_mode_tracker.dart';
@@ -93,6 +94,11 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
late final RefreshPacer _livePacer;
bool _inPlaceReload = false;
/// The effective [TickerMode] notifier this tab listens to for
/// hidden→visible transitions (a pushed opaque route popping). Rebound
/// when the ancestor changes; `null` until first bound.
ValueListenable<TickerModeData>? _tickerModeNotifier;
/// The library content epoch (see [LibrariesProvider.libraryContentEpoch])
/// this tab's data was loaded under.
int _loadedLibraryContentEpoch = 0;
@@ -170,15 +176,54 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
_liveLibrarySubscription = LibraryContentNotifier().stream.listen(_onLiveLibraryChange);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateTickerModeNotifier();
}
@override
void activate() {
super.activate();
// Reparented: the TickerMode ancestor may have changed.
_updateTickerModeNotifier();
}
@override
void dispose() {
invalidateLibraryLoad();
_refreshSubscription?.cancel();
_liveLibrarySubscription?.cancel();
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
_tickerModeNotifier = null;
_livePacer.dispose();
super.dispose();
}
/// Mirrors [TickerProviderStateMixin]: [TickerMode.getValuesNotifier] reads
/// the ancestor without registering a dependency, so rebind on the
/// lifecycle hooks where the ancestor can change.
void _updateTickerModeNotifier() {
final notifier = TickerMode.getValuesNotifier(context);
if (identical(notifier, _tickerModeNotifier)) return;
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
_tickerModeNotifier = notifier..addListener(_onSurfaceVisibilityChanged);
}
/// A push that landed while this tab sat under an opaque route (or behind
/// another main tab) was dropped by [_onLiveLibraryChange], but the
/// provider still marked the epoch. Nothing else observes the route pop —
/// `isActive` never flips — so replay it here through the same paced
/// in-place path a live event takes. Main-tab activation is already
/// consumed: [LibrariesScreen.onTabShown] reloads synchronously before the
/// frame that flips [TickerMode], so the epoch is current by the time this
/// fires and no second pass is scheduled.
void _onSurfaceVisibilityChanged() {
if (!mounted || !_isLiveSurfaceVisible) return;
if (!_isLibraryContentStale) return;
_livePacer.schedule();
}
@override
void didUpdateWidget(W oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -240,15 +285,26 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
_livePacer.notePass();
}
/// The provider's current epoch for this library, or `null` without a
/// [LibrariesProvider] (nothing marks staleness then).
int? _providerLibraryContentEpoch() {
return _librariesProviderOrNull()?.libraryContentEpoch(widget.library.globalKey);
}
/// Whether a server push marked this library's content since the last
/// committed load.
bool get _isLibraryContentStale {
final epoch = _providerLibraryContentEpoch();
return epoch != null && epoch != _loadedLibraryContentEpoch;
}
/// Reload once when a server push marked this library's content stale since
/// the last load (#1646). Called when the tab is next shown — never live —
/// so scroll position and focus are undisturbed while the user is on it.
void refreshIfLibraryContentStale() {
if (!mounted) return;
final provider = _librariesProviderOrNull();
if (provider == null) return;
final epoch = provider.libraryContentEpoch(widget.library.globalKey);
if (epoch == _loadedLibraryContentEpoch) return;
final epoch = _providerLibraryContentEpoch();
if (epoch == null || epoch == _loadedLibraryContentEpoch) return;
_loadedLibraryContentEpoch = epoch;
_libraryContentEpochAtLoadStart = epoch;
loadItems();
@@ -283,8 +339,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
void _onLiveLibraryChange(LibraryChangeEvent event) {
if (!mounted || !event.hasChanges || !_eventTargetsLibrary(event)) return;
// Only the active tab live-updates; hidden siblings reload on activation
// via [refreshIfLibraryContentStale] instead of fetching invisibly.
// Only the visible tab live-updates. Inactive siblings reload on
// activation via [refreshIfLibraryContentStale]; an active tab under a
// route or another main tab replays the drop when [TickerMode] re-enables
// it ([_onSurfaceVisibilityChanged]) — never fetch invisibly.
if (!_isLiveSurfaceVisible) return;
_livePacer.schedule();
}
@@ -438,6 +438,129 @@ void main() {
expect(loadCalls, 1, reason: 'offscreen surfaces consume staleness on activation instead');
});
testWidgets('a push dropped under an opaque route replays in place once the tab is visible again', (tester) async {
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
final provider = LibrariesProvider();
addTearDown(provider.dispose);
await provider.updateLibraryOrder(const [library]);
final key = GlobalKey<_ControlledTabState>();
final visible = ValueNotifier<bool>(true);
addTearDown(visible.dispose);
var loadCalls = 0;
Future<List<String>> load(MediaLibrary _) async {
loadCalls++;
return ['item $loadCalls'];
}
// Same shape as the Navigator overlay under a pushed opaque route: the
// active tab keeps `isActive`, only TickerMode flips.
await tester.pumpWidget(
ChangeNotifierProvider<LibrariesProvider>.value(
value: provider,
child: MaterialApp(
home: ValueListenableBuilder<bool>(
valueListenable: visible,
child: _ControlledTab(key: key, library: library, isActive: true, load: load),
builder: (_, value, child) => TickerMode(enabled: value, child: child!),
),
),
),
);
await tester.pump();
expect(loadCalls, 1);
expect(find.text('item 1'), findsOneWidget);
// Route pushed over the tab; a push lands and is dropped.
visible.value = false;
await tester.pump();
LibraryContentNotifier().notifyChanged(
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
);
await tester.pump();
await tester.pump(const Duration(minutes: 2, seconds: 4));
expect(loadCalls, 1, reason: 'a covered tab never fetches');
// Route popped: the owed refresh runs through the paced in-place path,
// not a clearing reload — old content stays rendered meanwhile.
visible.value = true;
await tester.pump();
expect(loadCalls, 1, reason: 'no immediate clearing reload');
await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(minutes: 2));
expect(loadCalls, 2, reason: 'exactly one paced pass');
await tester.pump();
expect(find.text('item 2'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
// Another round trip with no new push owes nothing.
visible.value = false;
await tester.pump();
visible.value = true;
await tester.pump();
await tester.pump(const Duration(minutes: 2, seconds: 4));
expect(loadCalls, 2, reason: 'a current epoch schedules no pass');
});
testWidgets('re-enabling TickerMode leaves an inactive tab to its activation reload', (tester) async {
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
final provider = LibrariesProvider();
addTearDown(provider.dispose);
await provider.updateLibraryOrder(const [library]);
final key = GlobalKey<_ControlledTabState>();
final visible = ValueNotifier<bool>(false);
addTearDown(visible.dispose);
var loadCalls = 0;
Future<List<String>> load(MediaLibrary _) async {
loadCalls++;
return const ['item'];
}
Future<void> pumpTab({required bool isActive}) => tester.pumpWidget(
ChangeNotifierProvider<LibrariesProvider>.value(
value: provider,
child: MaterialApp(
home: ValueListenableBuilder<bool>(
valueListenable: visible,
child: _ControlledTab(key: key, library: library, isActive: isActive, load: load),
builder: (_, value, child) => TickerMode(enabled: value, child: child!),
),
),
),
);
await pumpTab(isActive: false);
await tester.pump();
expect(loadCalls, 1);
LibraryContentNotifier().notifyChanged(
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
);
await tester.pump();
visible.value = true;
await tester.pump();
await tester.pump(const Duration(minutes: 2, seconds: 4));
expect(loadCalls, 1, reason: 'an inactive sibling is owned by didUpdateWidget');
// MainScreen ordering: onTabShown's clearing reload consumes the epoch
// synchronously, then the frame flips TickerMode. The flip must not
// schedule a second, paced pass.
visible.value = false;
await tester.pump();
LibraryContentNotifier().notifyChanged(
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
);
await tester.pump();
key.currentState!.refreshIfLibraryContentStale();
visible.value = true;
await pumpTab(isActive: true);
await tester.pump();
expect(loadCalls, 2, reason: 'activation reload');
await tester.pump(const Duration(minutes: 2, seconds: 4));
expect(loadCalls, 2, reason: 'activation already consumed the staleness');
});
testWidgets('an event naming only unknown library ids live-refreshes via the whole-server fallback', (tester) async {
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
final provider = LibrariesProvider();