Add stream language filter and fix directory infinite scroll.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m41s

Settings multi-select filters Top/category/tag lists via Helix (with over-fetch for multiple languages). Larger Helix pages, safer cursors, and scroll near-end loading restore pagination; GQL page-2 integrity failures end the list cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-10-01 10:51:10 +02:00
parent cff29232c7
commit 9d392d8d6a
12 changed files with 395 additions and 47 deletions

View file

@ -38,10 +38,16 @@ class TwitchGqlApi extends BaseApiClient {
headers: _gqlHeaders,
);
final errors = response['errors'];
if (errors is List && errors.isNotEmpty && response['data'] == null) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
throw ApiException(message);
if (errors is List && errors.isNotEmpty) {
final data = response['data'];
final dataEmpty = data == null ||
(data is Map &&
data.values.every((value) => value == null));
if (dataEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
throw ApiException(message);
}
}
return response;
}
@ -205,28 +211,42 @@ class TwitchGqlApi extends BaseApiClient {
.toList();
}
/// Live streams matching optional [freeformTags], sorted by viewer count.
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
///
/// Twitch caps `first` at 30 for this connection. Cursor pagination often
/// fails with `IntegrityCheckFailed` without a web integrity token — callers
/// should treat a failed page-2 as end-of-list.
Future<StreamsTwitch> getStreams({
List<String>? freeformTags,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql({
'operationName': 'FrostyTopStreams',
'query':
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort) {'
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags }) {'
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } }',
'variables': {
'first': first,
'first': cappedFirst,
'after': cursor,
'tags': freeformTags,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
});
@ -235,28 +255,38 @@ class TwitchGqlApi extends BaseApiClient {
return _streamsFromConnection(connection);
}
/// Live streams under a category/game, with viewer sort (Helix has no asc).
/// Live streams under a category/game, with viewer sort and optional languages.
Future<StreamsTwitch> getGameStreams({
required String gameId,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql({
'operationName': 'FrostyGameStreams',
'query':
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort) {'
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort }) {'
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } } }',
'variables': {
'id': gameId,
'first': first,
'first': cappedFirst,
'after': cursor,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
});
@ -270,7 +300,8 @@ class TwitchGqlApi extends BaseApiClient {
StreamsTwitch _streamsFromConnection(JsonMap connection) {
final edges = (connection['edges'] as List?) ?? const [];
final pageInfo = connection['pageInfo'] as JsonMap?;
final hasNext = pageInfo?['hasNextPage'] as bool? ?? false;
// Prefer explicit pageInfo; if missing, assume more when we got a full page.
final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30;
final streams = <StreamTwitch>[];
String? nextCursor;
@ -285,7 +316,9 @@ class TwitchGqlApi extends BaseApiClient {
return StreamsTwitch(
streams,
hasNext && nextCursor != null ? {'cursor': nextCursor} : const {},
hasNext && nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
);
}
@ -293,6 +326,7 @@ class TwitchGqlApi extends BaseApiClient {
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
final game = node['game'] as JsonMap? ?? const {};
final login = broadcaster['login'] as String? ?? '';
final language = (node['language'] as String? ?? '').toLowerCase();
return StreamTwitch(
broadcaster['id'] as String? ?? '',
@ -305,6 +339,7 @@ class TwitchGqlApi extends BaseApiClient {
node['createdAt'] as String? ?? '',
// Helix-style placeholder so existing thumbnail widgets keep working.
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
language,
);
}
}