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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue