diff --git a/DESIGN.md b/DESIGN.md index 25adaf1d..6be25c9d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/assets/release-notes.md b/assets/release-notes.md index 26387850..8af12647 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -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 diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index a1631949..bc05259b 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -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: - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + 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, + ); + } break; case ListType.category: - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + 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, + ); + } break; case ListType.tag: - // Helix + client tag filter (GQL freeformTags can't paginate without - // a non-bot Client-Integrity token). - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - freeformTag: freeformTag, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + 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, + ); + } 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.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 setSortOrder(StreamViewerSort order) async { diff --git a/pubspec.yaml b/pubspec.yaml index 45974ec4..7b28e0fb 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.5+75 +version: 5.2.6+76 environment: sdk: ^3.9.0