From 318dc84dc57267c916f81f996c86f10de156a648 Mon Sep 17 00:00:00 2001 From: copilot Date: Thu, 1 Oct 2026 12:23:15 +0200 Subject: [PATCH] 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 --- DESIGN.md | 6 +- assets/release-notes.md | 10 ++ lib/apis/twitch_api.dart | 18 +++- .../home/stream_list/stream_list_store.dart | 102 ++++++++++++++---- pubspec.yaml | 2 +- 5 files changed, 111 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6be25c9d..3ad3817c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/assets/release-notes.md b/assets/release-notes.md index 8af12647..28d3b3e9 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -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 diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index a1a7e4c2..f4bd8152 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -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 getDirectoryStreams({ List 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 {}, ); } diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index bc05259b..f8e97b69 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -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.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 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 } diff --git a/pubspec.yaml b/pubspec.yaml index 7b28e0fb..8e1a5656 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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