Fix fewest-viewers sort via GQL VIEWER_COUNT_ASC.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m16s

Helix directories are viewer-desc only; local reverse of top pages wrongly showed high-viewer streams for ascending sort.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-10-01 12:03:12 +02:00
parent 47150d0ab3
commit 5a787e0134
4 changed files with 68 additions and 44 deletions

View file

@ -50,8 +50,10 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable)
- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity
- 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
## Build

View file

@ -1,3 +1,13 @@
## v5.2.6
October 1, 2026
### Bug Fixes
- Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally.
---
## v5.2.1
September 30, 2026

View file

@ -260,12 +260,6 @@ abstract class ListStoreBase with Store {
try {
final languages = _languageFilter;
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
// Asc needs a deeper Helix scan so client-side re-sort can surface
// lower-viewer streams (Helix itself is always viewer-desc).
final minCount = isAsc ? 80 : 50;
final maxPages = isAsc
? (_streamsCursor == null ? 20 : 10)
: (_streamsCursor == null ? 12 : 8);
final StreamsTwitch newStreams;
switch (listType) {
@ -276,36 +270,56 @@ abstract class ListStoreBase with Store {
);
break;
case ListType.top:
if (isAsc && twitchGqlApi != null) {
// Helix is always viewer-desc — fewest must be requested via GQL.
newStreams = await twitchGqlApi!.getStreams(
sort: StreamViewerSort.viewersAsc,
languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor,
);
} else {
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
}
break;
case ListType.category:
if (isAsc && twitchGqlApi != null) {
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: minCount,
maxPages: maxPages,
);
}
break;
case ListType.tag:
// Helix + client tag filter (GQL freeformTags can't paginate without
// a non-bot Client-Integrity token).
if (isAsc && twitchGqlApi != null) {
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).
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
freeformTag: freeformTag,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
}
break;
}
// Empty continuation ⇒ end of directory.
// Empty continuation ⇒ end of directory (incl. GQL integrity dead-ends).
final isContinuation = _streamsCursor != null;
if (isContinuation && newStreams.data.isEmpty) {
_streamsCursor = null;
@ -313,18 +327,25 @@ abstract class ListStoreBase with Store {
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.
final pageData = isAsc
? (List<StreamTwitch>.of(newStreams.data)..sort(
(a, b) => a.viewerCount.compareTo(b.viewerCount),
))
: newStreams.data;
if (_streamsCursor == null) {
_allStreams = newStreams.data.asObservable();
_allStreams = pageData.asObservable();
} else {
final existingIds = _allStreams.map((s) => s.userId).toSet();
_allStreams.addAll(
newStreams.data.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;
_applyViewerSort();
_error = null;
} on SocketException {
_error = 'Unable to connect to Twitch';
@ -351,15 +372,6 @@ abstract class ListStoreBase with Store {
}
}
void _applyViewerSort() {
final sorted = _allStreams.toList();
sorted.sort((a, b) {
final cmp = a.viewerCount.compareTo(b.viewerCount);
return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp;
});
_allStreams = sorted.asObservable();
}
/// Persists viewer sort and reloads via the settings reaction.
@action
Future<void> setSortOrder(StreamViewerSort order) async {

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.5+75
version: 5.2.6+76
environment:
sdk: ^3.9.0