diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index c10ebd5b8..19d59ceff 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -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> 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? _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> 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> 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> 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(); } diff --git a/test/screens/libraries/base_library_tab_focus_test.dart b/test/screens/libraries/base_library_tab_focus_test.dart index c341f4e82..2d8257d81 100644 --- a/test/screens/libraries/base_library_tab_focus_test.dart +++ b/test/screens/libraries/base_library_tab_focus_test.dart @@ -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(true); + addTearDown(visible.dispose); + var loadCalls = 0; + Future> 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.value( + value: provider, + child: MaterialApp( + home: ValueListenableBuilder( + 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(false); + addTearDown(visible.dispose); + var loadCalls = 0; + Future> load(MediaLibrary _) async { + loadCalls++; + return const ['item']; + } + + Future pumpTab({required bool isActive}) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + home: ValueListenableBuilder( + 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();