fix(discover): avoid progress refresh fanout

This commit is contained in:
edde746
2026-07-12 08:42:21 +02:00
parent 7ed342b345
commit 1b9ef3b3b6
2 changed files with 64 additions and 9 deletions
+16 -6
View File
@@ -24,12 +24,12 @@ enum DiscoverLoadState { initial, loading, loaded, error }
/// Owns the Discover tab's data: the Continue Watching row and the home hub
/// list, including the refresh policy that used to live in the screen —
/// watch events refresh only Continue Watching (one on-deck call, zero hub
/// refetches), deletions drop the item from every visible list in place and
/// then refresh only Continue Watching, hidden-library changes trigger a
/// full reload, library-order changes re-sort hubs in place without
/// refetching, and the platform launcher shelf syncs from every on-deck
/// update.
/// durable watch events refresh only Continue Watching (one on-deck call,
/// zero hub refetches), playback progress patches the visible row in place,
/// deletions drop the item from every visible list in place and then refresh
/// only Continue Watching, hidden-library changes trigger a full reload,
/// library-order changes re-sort hubs in place without refetching, and the
/// platform launcher shelf syncs from every on-deck update.
///
/// Lives inside the profile-keyed provider subtree, so a profile switch
/// resets it by construction. The screen is a consumer: it renders this
@@ -476,6 +476,16 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
void _onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
final viewOffset = event.viewOffset;
final index = _onDeck.indexWhere((item) => item.globalKey == event.globalKey);
if (viewOffset != null && index != -1 && _onDeck[index].viewOffsetMs != viewOffset) {
_onDeck = List.of(_onDeck)..[index] = _onDeck[index].copyWith(viewOffsetMs: viewOffset);
safeNotifyListeners();
}
return;
}
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
final remaining = _onDeck.where((item) => item.id != event.itemId).toList();
if (remaining.length != _onDeck.length) {
+48 -3
View File
@@ -54,8 +54,9 @@ MediaHub _hub(
);
/// Counting fake — the provider's fetch-cost policy is the contract under
/// test: a watch event must cost exactly one on-deck call and zero hub
/// refetches, an order change zero calls, a hidden-set change one full pass.
/// test: a durable watch event must cost exactly one on-deck call and zero hub
/// refetches, progress zero calls, an order change zero calls, and a hidden-set
/// change one full pass.
class _FakeAggregationService extends DataAggregationService {
_FakeAggregationService(super.serverManager);
@@ -198,7 +199,7 @@ void main() {
expect(provider.hubs.map((h) => h.id), ['keep']);
});
test('watch event refreshes continue watching with one call and zero hub refetches', () async {
test('watched and unwatched events refresh continue watching only', () async {
aggregation.onDeckResult = () => [_item('ep-1', parentId: 'season-1')];
aggregation.hubsResult = () => [_hub('hub-1')];
await provider.load();
@@ -210,11 +211,53 @@ void main() {
expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
expect(aggregation.hubCalls, hubCallsBefore);
WatchStateNotifier().notifyWatched(item: _item('ep-1', parentId: 'season-1'), isNowWatched: false);
await pumpEventQueue();
expect(aggregation.onDeckCalls, onDeckCallsBefore + 2);
expect(aggregation.hubCalls, hubCallsBefore);
});
test('sub-threshold progress patches the row without refetching', () async {
final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 10000, viewCount: 0);
aggregation.onDeckResult = () => [playing, for (var i = 2; i <= 21; i++) _item('ep-$i')];
aggregation.hubsResult = () => [_hub('hub-1')];
await provider.load();
final onDeckCallsBefore = aggregation.onDeckCalls;
final hubCallsBefore = aggregation.hubCalls;
WatchStateNotifier().notifyProgress(item: playing, viewOffset: 30000, duration: 100000);
await pumpEventQueue();
expect(provider.onDeck.first.viewOffsetMs, 30000);
expect(provider.onDeck.first.isWatched, isFalse);
expect(provider.onDeck, hasLength(DiscoverProvider.continueWatchingPreviewLimit));
expect(provider.hasMoreContinueWatching, isTrue);
expect(aggregation.onDeckCalls, onDeckCallsBefore);
expect(aggregation.hubCalls, hubCallsBefore);
});
test('watched-threshold progress refreshes continue watching only', () async {
final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 80000);
aggregation.onDeckResult = () => [playing];
aggregation.hubsResult = () => [_hub('hub-1')];
await provider.load();
final onDeckCallsBefore = aggregation.onDeckCalls;
final hubCallsBefore = aggregation.hubCalls;
WatchStateNotifier().notifyProgress(item: playing, viewOffset: 95000, duration: 100000);
await pumpEventQueue();
expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
expect(aggregation.hubCalls, hubCallsBefore);
});
test('removal event drops the row immediately, then refreshes in background', () async {
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
await provider.load();
final onDeckCallsBefore = aggregation.onDeckCalls;
final hubCallsBefore = aggregation.hubCalls;
var sawImmediateRemoval = false;
provider.addListener(() {
@@ -229,6 +272,8 @@ void main() {
expect(sawImmediateRemoval, isTrue);
expect(provider.onDeck.map((i) => i.id), ['ep-2']);
expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
expect(aggregation.hubCalls, hubCallsBefore);
});
test('deletion drops the item from on-deck and hubs, then refreshes continue watching only', () async {