Fix tag infinite scroll after GQL ASC first page.
GQL page-2 fails integrity, so fewest starts with GQL then continues via Helix tag filter with dedupe; empty Helix windows no longer end the list while a cursor remains. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5a787e0134
commit
318dc84dc5
5 changed files with 111 additions and 27 deletions
|
|
@ -254,6 +254,10 @@ class TwitchApi extends BaseApiClient {
|
|||
/// Helix is always viewer-desc and supports at most one `language` query
|
||||
/// param; tags are filtered client-side. Prefer this over GQL for pagination
|
||||
/// (GQL page-2 requires a non-bot Client-Integrity token).
|
||||
///
|
||||
/// When a [freeformTag] filter yields no matches in a Helix window, this
|
||||
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
|
||||
/// callers can continue — an empty match set alone is not end-of-list.
|
||||
Future<StreamsTwitch> getDirectoryStreams({
|
||||
List<String> languages = const [],
|
||||
String? gameId,
|
||||
|
|
@ -293,6 +297,10 @@ class TwitchApi extends BaseApiClient {
|
|||
);
|
||||
}
|
||||
|
||||
// Sparse tags need a deeper Helix scan per request.
|
||||
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
|
||||
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
|
||||
|
||||
// When filtering by tag and exactly one language, still pass language to
|
||||
// Helix so each page is denser in matches.
|
||||
final helixLanguage =
|
||||
|
|
@ -303,7 +311,7 @@ class TwitchApi extends BaseApiClient {
|
|||
String? nextCursor;
|
||||
var pages = 0;
|
||||
|
||||
while (matched.length < minCount && pages < maxPages) {
|
||||
while (matched.length < effectiveMin && pages < effectiveMax) {
|
||||
final page = gameId == null
|
||||
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||||
: await getStreamsUnderCategory(
|
||||
|
|
@ -316,7 +324,9 @@ class TwitchApi extends BaseApiClient {
|
|||
for (final stream in page.data) {
|
||||
if (matches(stream)) matched.add(stream);
|
||||
}
|
||||
if (nextCursor == null || page.data.isEmpty) {
|
||||
if (nextCursor == null ||
|
||||
nextCursor.isEmpty ||
|
||||
page.data.isEmpty) {
|
||||
nextCursor = null;
|
||||
break;
|
||||
}
|
||||
|
|
@ -325,7 +335,9 @@ class TwitchApi extends BaseApiClient {
|
|||
|
||||
return StreamsTwitch(
|
||||
matched,
|
||||
nextCursor != null ? {'cursor': nextCursor} : const {},
|
||||
nextCursor != null && nextCursor.isNotEmpty
|
||||
? {'cursor': nextCursor}
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ abstract class ListStoreBase with Store {
|
|||
/// The pagination cursor for the streams.
|
||||
String? _streamsCursor;
|
||||
|
||||
/// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity
|
||||
/// fails). Helix starts from the top; duplicates are skipped.
|
||||
var _ascHelixFill = false;
|
||||
|
||||
/// The pagination cursor for the offline followed channels.
|
||||
String? _offlineChannelsCursor;
|
||||
|
||||
|
|
@ -59,7 +63,8 @@ abstract class ListStoreBase with Store {
|
|||
|
||||
/// Returns whether or not there are more streams and loading status for pagination.
|
||||
@computed
|
||||
bool get hasMore => isLoading == false && _streamsCursor != null;
|
||||
bool get hasMore =>
|
||||
isLoading == false && (_streamsCursor != null || _ascHelixFill);
|
||||
|
||||
@computed
|
||||
bool get isLoading =>
|
||||
|
|
@ -260,6 +265,13 @@ abstract class ListStoreBase with Store {
|
|||
try {
|
||||
final languages = _languageFilter;
|
||||
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||
// GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed.
|
||||
// First empty list → GQL ASC once; further pages → Helix fill + dedupe.
|
||||
final gqlAscFirstPage = isAsc &&
|
||||
twitchGqlApi != null &&
|
||||
!_ascHelixFill &&
|
||||
_allStreams.isEmpty &&
|
||||
listType != ListType.followed;
|
||||
|
||||
final StreamsTwitch newStreams;
|
||||
switch (listType) {
|
||||
|
|
@ -270,72 +282,88 @@ abstract class ListStoreBase with Store {
|
|||
);
|
||||
break;
|
||||
case ListType.top:
|
||||
if (isAsc && twitchGqlApi != null) {
|
||||
// Helix is always viewer-desc — fewest must be requested via GQL.
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getStreams(
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
cursor: _streamsCursor,
|
||||
minCount: isAsc ? 30 : 50,
|
||||
maxPages: isAsc ? 20 : 12,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ListType.category:
|
||||
if (isAsc && twitchGqlApi != null) {
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getGameStreams(
|
||||
gameId: categoryId!,
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
gameId: categoryId!,
|
||||
cursor: _streamsCursor,
|
||||
minCount: isAsc ? 30 : 50,
|
||||
maxPages: isAsc ? 20 : 12,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ListType.tag:
|
||||
if (isAsc && twitchGqlApi != null) {
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getStreams(
|
||||
freeformTags: [freeformTag!],
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
} else {
|
||||
// Desc: Helix + tag filter (GQL page-2 needs Client-Integrity).
|
||||
// Desc, and ASC after GQL: Helix + tag filter (paginates).
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
freeformTag: freeformTag,
|
||||
cursor: _streamsCursor,
|
||||
minCount: 40,
|
||||
maxPages: 25,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Empty continuation ⇒ end of directory (incl. GQL integrity dead-ends).
|
||||
final isContinuation = _streamsCursor != null;
|
||||
if (isContinuation && newStreams.data.isEmpty) {
|
||||
final next = newStreams.pagination['cursor'];
|
||||
final hasHelixCursor = next != null && next.isNotEmpty;
|
||||
|
||||
// Empty Helix window with cursor left ⇒ keep scanning (sparse tags).
|
||||
final isHelixContinuation =
|
||||
_streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty);
|
||||
if (isHelixContinuation &&
|
||||
newStreams.data.isEmpty &&
|
||||
!gqlAscFirstPage) {
|
||||
if (hasHelixCursor) {
|
||||
_streamsCursor = next;
|
||||
_error = null;
|
||||
_isAllStreamsLoading = false;
|
||||
await getStreams();
|
||||
return;
|
||||
}
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep API order. Only sanitize each ASC page — Twitch ASC can briefly
|
||||
// mis-order within a page; never re-sort a Helix desc directory locally.
|
||||
// Keep API order for desc. ASC: sanitize page, then full-list sort after
|
||||
// Helix fill so newly found streams land in fewest-first order.
|
||||
final pageData = isAsc
|
||||
? (List<StreamTwitch>.of(newStreams.data)..sort(
|
||||
(a, b) => a.viewerCount.compareTo(b.viewerCount),
|
||||
))
|
||||
: newStreams.data;
|
||||
|
||||
if (_streamsCursor == null) {
|
||||
if (_allStreams.isEmpty && !_ascHelixFill) {
|
||||
_allStreams = pageData.asObservable();
|
||||
} else {
|
||||
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||
|
|
@ -343,8 +371,25 @@ abstract class ListStoreBase with Store {
|
|||
pageData.where((s) => !existingIds.contains(s.userId)),
|
||||
);
|
||||
}
|
||||
final next = newStreams.pagination['cursor'];
|
||||
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
|
||||
|
||||
if (isAsc && _allStreams.length > 1) {
|
||||
final sorted = _allStreams.toList()
|
||||
..sort((a, b) => a.viewerCount.compareTo(b.viewerCount));
|
||||
_allStreams = sorted.asObservable();
|
||||
}
|
||||
|
||||
if (gqlAscFirstPage) {
|
||||
// Ignore GQL cursor — page-2 integrity fails. Fill via Helix next.
|
||||
_ascHelixFill = true;
|
||||
_streamsCursor = null;
|
||||
} else {
|
||||
_streamsCursor = hasHelixCursor ? next : null;
|
||||
if (_streamsCursor == null) {
|
||||
_ascHelixFill = false;
|
||||
} else if (isAsc) {
|
||||
_ascHelixFill = true;
|
||||
}
|
||||
}
|
||||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
|
|
@ -352,19 +397,31 @@ abstract class ListStoreBase with Store {
|
|||
debugPrint('Streams SocketException: No internet connection');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Streams ApiException: $e');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
if (_allStreams.isNotEmpty) {
|
||||
// GQL page-2 integrity or mid-scroll error: fall back to Helix fill.
|
||||
if (isAscDirectory && !_ascHelixFill) {
|
||||
_ascHelixFill = true;
|
||||
_streamsCursor = null;
|
||||
_error = null;
|
||||
_isAllStreamsLoading = false;
|
||||
await getStreams();
|
||||
return;
|
||||
}
|
||||
_streamsCursor = null;
|
||||
if (_allStreams.isNotEmpty) _error = null;
|
||||
_ascHelixFill = false;
|
||||
_error = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_error = 'Something went wrong loading streams';
|
||||
debugPrint('Streams error: $e');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_error = null;
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -372,6 +429,10 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
bool get isAscDirectory =>
|
||||
sortOrder == StreamViewerSort.viewersAsc &&
|
||||
listType != ListType.followed;
|
||||
|
||||
/// Persists viewer sort and reloads via the settings reaction.
|
||||
@action
|
||||
Future<void> setSortOrder(StreamViewerSort order) async {
|
||||
|
|
@ -453,6 +514,7 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
await getStreams();
|
||||
}
|
||||
|
||||
|
|
@ -498,5 +560,5 @@ abstract class ListStoreBase with Store {
|
|||
/// [ListType.followed] is the list of streams that the user is following.
|
||||
/// [ListType.top] is the list of top streams.
|
||||
/// [ListType.category] is the list of streams under a category.
|
||||
/// [ListType.tag] is the list of streams matching a freeform tag (GQL).
|
||||
/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page).
|
||||
enum ListType { followed, top, category, tag }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue