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 - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`) - Viewer sort is persisted (`streamViewerSort`)
- **Most viewers (desc):** Helix directory order (paginates) - **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match)
- **Fewest viewers (asc):** Twitch GQL `VIEWER_COUNT_ASC` at request time (Helix cannot ascend; local reverse of Helix top pages was wrong) - **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 (desc): Helix `tags[]` matching for scroll; asc uses GQL with the same tag/language filters - Tag directories never rely on GQL cursors for load-more
## Build ## 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 ## v5.2.6
October 1, 2026 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 /// Helix is always viewer-desc and supports at most one `language` query
/// param; tags are filtered client-side. Prefer this over GQL for pagination /// param; tags are filtered client-side. Prefer this over GQL for pagination
/// (GQL page-2 requires a non-bot Client-Integrity token). /// (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({ Future<StreamsTwitch> getDirectoryStreams({
List<String> languages = const [], List<String> languages = const [],
String? gameId, 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 // When filtering by tag and exactly one language, still pass language to
// Helix so each page is denser in matches. // Helix so each page is denser in matches.
final helixLanguage = final helixLanguage =
@ -303,7 +311,7 @@ class TwitchApi extends BaseApiClient {
String? nextCursor; String? nextCursor;
var pages = 0; var pages = 0;
while (matched.length < minCount && pages < maxPages) { while (matched.length < effectiveMin && pages < effectiveMax) {
final page = gameId == null final page = gameId == null
? await getTopStreams(cursor: pageCursor, language: helixLanguage) ? await getTopStreams(cursor: pageCursor, language: helixLanguage)
: await getStreamsUnderCategory( : await getStreamsUnderCategory(
@ -316,7 +324,9 @@ class TwitchApi extends BaseApiClient {
for (final stream in page.data) { for (final stream in page.data) {
if (matches(stream)) matched.add(stream); if (matches(stream)) matched.add(stream);
} }
if (nextCursor == null || page.data.isEmpty) { if (nextCursor == null ||
nextCursor.isEmpty ||
page.data.isEmpty) {
nextCursor = null; nextCursor = null;
break; break;
} }
@ -325,7 +335,9 @@ class TwitchApi extends BaseApiClient {
return StreamsTwitch( return StreamsTwitch(
matched, 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. /// The pagination cursor for the streams.
String? _streamsCursor; 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. /// The pagination cursor for the offline followed channels.
String? _offlineChannelsCursor; String? _offlineChannelsCursor;
@ -59,7 +63,8 @@ abstract class ListStoreBase with Store {
/// Returns whether or not there are more streams and loading status for pagination. /// Returns whether or not there are more streams and loading status for pagination.
@computed @computed
bool get hasMore => isLoading == false && _streamsCursor != null; bool get hasMore =>
isLoading == false && (_streamsCursor != null || _ascHelixFill);
@computed @computed
bool get isLoading => bool get isLoading =>
@ -260,6 +265,13 @@ abstract class ListStoreBase with Store {
try { try {
final languages = _languageFilter; final languages = _languageFilter;
final isAsc = sortOrder == StreamViewerSort.viewersAsc; 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; final StreamsTwitch newStreams;
switch (listType) { switch (listType) {
@ -270,72 +282,88 @@ abstract class ListStoreBase with Store {
); );
break; break;
case ListType.top: case ListType.top:
if (isAsc && twitchGqlApi != null) { if (gqlAscFirstPage) {
// Helix is always viewer-desc — fewest must be requested via GQL.
newStreams = await twitchGqlApi!.getStreams( newStreams = await twitchGqlApi!.getStreams(
sort: StreamViewerSort.viewersAsc, sort: StreamViewerSort.viewersAsc,
languages: languages.isEmpty ? null : languages, languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor,
); );
} else { } else {
newStreams = await twitchApi.getDirectoryStreams( newStreams = await twitchApi.getDirectoryStreams(
languages: languages, languages: languages,
cursor: _streamsCursor, cursor: _streamsCursor,
minCount: isAsc ? 30 : 50,
maxPages: isAsc ? 20 : 12,
); );
} }
break; break;
case ListType.category: case ListType.category:
if (isAsc && twitchGqlApi != null) { if (gqlAscFirstPage) {
newStreams = await twitchGqlApi!.getGameStreams( newStreams = await twitchGqlApi!.getGameStreams(
gameId: categoryId!, gameId: categoryId!,
sort: StreamViewerSort.viewersAsc, sort: StreamViewerSort.viewersAsc,
languages: languages.isEmpty ? null : languages, languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor,
); );
} else { } else {
newStreams = await twitchApi.getDirectoryStreams( newStreams = await twitchApi.getDirectoryStreams(
languages: languages, languages: languages,
gameId: categoryId!, gameId: categoryId!,
cursor: _streamsCursor, cursor: _streamsCursor,
minCount: isAsc ? 30 : 50,
maxPages: isAsc ? 20 : 12,
); );
} }
break; break;
case ListType.tag: case ListType.tag:
if (isAsc && twitchGqlApi != null) { if (gqlAscFirstPage) {
newStreams = await twitchGqlApi!.getStreams( newStreams = await twitchGqlApi!.getStreams(
freeformTags: [freeformTag!], freeformTags: [freeformTag!],
sort: StreamViewerSort.viewersAsc, sort: StreamViewerSort.viewersAsc,
languages: languages.isEmpty ? null : languages, languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor,
); );
} else { } else {
// Desc: Helix + tag filter (GQL page-2 needs Client-Integrity). // Desc, and ASC after GQL: Helix + tag filter (paginates).
newStreams = await twitchApi.getDirectoryStreams( newStreams = await twitchApi.getDirectoryStreams(
languages: languages, languages: languages,
freeformTag: freeformTag, freeformTag: freeformTag,
cursor: _streamsCursor, cursor: _streamsCursor,
minCount: 40,
maxPages: 25,
); );
} }
break; break;
} }
// Empty continuation ⇒ end of directory (incl. GQL integrity dead-ends). final next = newStreams.pagination['cursor'];
final isContinuation = _streamsCursor != null; final hasHelixCursor = next != null && next.isNotEmpty;
if (isContinuation && newStreams.data.isEmpty) {
// 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; _streamsCursor = null;
_ascHelixFill = false;
_error = null; _error = null;
return; return;
} }
// Keep API order. Only sanitize each ASC page — Twitch ASC can briefly // Keep API order for desc. ASC: sanitize page, then full-list sort after
// mis-order within a page; never re-sort a Helix desc directory locally. // Helix fill so newly found streams land in fewest-first order.
final pageData = isAsc final pageData = isAsc
? (List<StreamTwitch>.of(newStreams.data)..sort( ? (List<StreamTwitch>.of(newStreams.data)..sort(
(a, b) => a.viewerCount.compareTo(b.viewerCount), (a, b) => a.viewerCount.compareTo(b.viewerCount),
)) ))
: newStreams.data; : newStreams.data;
if (_streamsCursor == null) { if (_allStreams.isEmpty && !_ascHelixFill) {
_allStreams = pageData.asObservable(); _allStreams = pageData.asObservable();
} else { } else {
final existingIds = _allStreams.map((s) => s.userId).toSet(); final existingIds = _allStreams.map((s) => s.userId).toSet();
@ -343,8 +371,25 @@ abstract class ListStoreBase with Store {
pageData.where((s) => !existingIds.contains(s.userId)), 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; _error = null;
} on SocketException { } on SocketException {
@ -352,19 +397,31 @@ abstract class ListStoreBase with Store {
debugPrint('Streams SocketException: No internet connection'); debugPrint('Streams SocketException: No internet connection');
if (_streamsCursor != null && _allStreams.isNotEmpty) { if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null; _streamsCursor = null;
_ascHelixFill = false;
} }
} on ApiException catch (e) { } on ApiException catch (e) {
_error = e.message; _error = e.message;
debugPrint('Streams ApiException: $e'); 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; _streamsCursor = null;
if (_allStreams.isNotEmpty) _error = null; _ascHelixFill = false;
_error = null;
} }
} catch (e) { } catch (e) {
_error = 'Something went wrong loading streams'; _error = 'Something went wrong loading streams';
debugPrint('Streams error: $e'); debugPrint('Streams error: $e');
if (_streamsCursor != null && _allStreams.isNotEmpty) { if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null; _streamsCursor = null;
_ascHelixFill = false;
_error = null; _error = null;
} }
} finally { } 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. /// Persists viewer sort and reloads via the settings reaction.
@action @action
Future<void> setSortOrder(StreamViewerSort order) async { Future<void> setSortOrder(StreamViewerSort order) async {
@ -453,6 +514,7 @@ abstract class ListStoreBase with Store {
} }
_streamsCursor = null; _streamsCursor = null;
_ascHelixFill = false;
await getStreams(); await getStreams();
} }
@ -498,5 +560,5 @@ abstract class ListStoreBase with Store {
/// [ListType.followed] is the list of streams that the user is following. /// [ListType.followed] is the list of streams that the user is following.
/// [ListType.top] is the list of top streams. /// [ListType.top] is the list of top streams.
/// [ListType.category] is the list of streams under a category. /// [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 } 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 # 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 # 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. # 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: environment:
sdk: ^3.9.0 sdk: ^3.9.0