Fix tag infinite scroll after GQL ASC first page.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m37s

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:
copilot 2026-10-01 12:23:15 +02:00
parent 5a787e0134
commit 318dc84dc5
5 changed files with 111 additions and 27 deletions

View file

@ -51,9 +51,9 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`)
- **Most viewers (desc):** Helix directory order (paginates)
- **Fewest viewers (asc):** Twitch GQL `VIEWER_COUNT_ASC` at request time (Helix cannot ascend; local reverse of Helix top pages was wrong)
- Tag directories (desc): Helix `tags[]` matching for scroll; asc uses GQL with the same tag/language filters
- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match)
- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails)
- Tag directories never rely on GQL cursors for load-more
## Build

View file

@ -1,3 +1,13 @@
## v5.2.7
October 1, 2026
### Bug Fixes
- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page.
---
## v5.2.6
October 1, 2026

View file

@ -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 {},
);
}

View file

@ -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 }

View file

@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 5.2.6+76
version: 5.2.7+77
environment:
sdk: ^3.9.0