From 1b9ef3b3b6d55d9249fbf870a5b4747a4cf52d50 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:12:02 +0200 Subject: [PATCH] fix(discover): avoid progress refresh fanout --- lib/providers/discover_provider.dart | 22 +++++++--- test/providers/discover_provider_test.dart | 51 ++++++++++++++++++++-- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 731997c1..cf7ec1c5 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -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) { diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index 7876fb82..8306fc5a 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -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 {