Add stream language filter and fix directory infinite scroll.
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:
parent
cff29232c7
commit
9d392d8d6a
12 changed files with 395 additions and 47 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue