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