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
10
DESIGN.md
10
DESIGN.md
|
|
@ -45,6 +45,16 @@ Helix cannot filter by freeform tags or sort ascending. GQL covers both:
|
|||
|
||||
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
|
||||
|
||||
## Stream languages (v5.2.4+)
|
||||
|
||||
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
|
||||
|
||||
- One language: Helix `language=` (paginates cleanly, `first=100`)
|
||||
- Several languages: Helix over-fetch + client filter (still paginated)
|
||||
- Tags / ascending sort: GQL with `languages: [Language!]`; page-2 often hits Twitch integrity — treated as end-of-list after the first page of 30
|
||||
|
||||
Also fixed infinite scroll: larger Helix pages, defensive cursors, scroll near-end load-more, and stop disposing shared Home scroll controllers.
|
||||
|
||||
## Build
|
||||
|
||||
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.
|
||||
|
|
|
|||
|
|
@ -175,11 +175,23 @@ class TwitchApi extends BaseApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
|
||||
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
|
||||
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
|
||||
///
|
||||
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
|
||||
Future<StreamsTwitch> getTopStreams({
|
||||
String? cursor,
|
||||
String? language,
|
||||
int first = 100,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{
|
||||
'first': first,
|
||||
if (cursor != null) 'after': cursor,
|
||||
if (language != null && language.isNotEmpty) 'language': language,
|
||||
};
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
'/streams',
|
||||
queryParameters: cursor != null ? {'after': cursor} : null,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
|
|
@ -190,7 +202,7 @@ class TwitchApi extends BaseApiClient {
|
|||
required String id,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = {'user_id': id};
|
||||
final queryParams = {'user_id': id, 'first': '100'};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
|
|
@ -206,7 +218,7 @@ class TwitchApi extends BaseApiClient {
|
|||
required String userId,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = {'user_id': userId, 'first': '20'};
|
||||
final queryParams = {'user_id': userId, 'first': '100'};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
|
|
@ -221,15 +233,82 @@ class TwitchApi extends BaseApiClient {
|
|||
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||
required String gameId,
|
||||
String? cursor,
|
||||
String? language,
|
||||
int first = 100,
|
||||
}) async {
|
||||
final queryParams = {'game_id': gameId};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
final queryParams = <String, dynamic>{
|
||||
'game_id': gameId,
|
||||
'first': first,
|
||||
if (cursor != null) 'after': cursor,
|
||||
if (language != null && language.isNotEmpty) 'language': language,
|
||||
};
|
||||
|
||||
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
}
|
||||
|
||||
/// Fetches Helix stream pages until [minCount] streams match [languages]
|
||||
/// (or the directory is exhausted). Preserves Helix viewer order.
|
||||
///
|
||||
/// Used when the user selected more than one language — Helix only accepts a
|
||||
/// single `language` query param.
|
||||
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
||||
required List<String> languages,
|
||||
String? gameId,
|
||||
String? cursor,
|
||||
int minCount = 40,
|
||||
int maxPages = 8,
|
||||
}) async {
|
||||
final wanted = languages
|
||||
.map((l) => l.trim().toLowerCase())
|
||||
.where((l) => l.isNotEmpty)
|
||||
.toSet();
|
||||
if (wanted.isEmpty) {
|
||||
return gameId == null
|
||||
? getTopStreams(cursor: cursor)
|
||||
: getStreamsUnderCategory(gameId: gameId, cursor: cursor);
|
||||
}
|
||||
if (wanted.length == 1) {
|
||||
final lang = wanted.first;
|
||||
return gameId == null
|
||||
? getTopStreams(cursor: cursor, language: lang)
|
||||
: getStreamsUnderCategory(
|
||||
gameId: gameId,
|
||||
cursor: cursor,
|
||||
language: lang,
|
||||
);
|
||||
}
|
||||
|
||||
final matched = <StreamTwitch>[];
|
||||
String? pageCursor = cursor;
|
||||
String? nextCursor;
|
||||
var pages = 0;
|
||||
|
||||
while (matched.length < minCount && pages < maxPages) {
|
||||
final page = gameId == null
|
||||
? await getTopStreams(cursor: pageCursor)
|
||||
: await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor);
|
||||
pages++;
|
||||
nextCursor = page.pagination['cursor'];
|
||||
for (final stream in page.data) {
|
||||
if (wanted.contains(stream.language.toLowerCase())) {
|
||||
matched.add(stream);
|
||||
}
|
||||
}
|
||||
if (nextCursor == null || page.data.isEmpty) {
|
||||
nextCursor = null;
|
||||
break;
|
||||
}
|
||||
pageCursor = nextCursor;
|
||||
}
|
||||
|
||||
return StreamsTwitch(
|
||||
matched,
|
||||
nextCursor != null ? {'cursor': nextCursor} : const {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||||
final data = await get<JsonMap>(
|
||||
|
|
|
|||
|
|
@ -38,11 +38,17 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
headers: _gqlHeaders,
|
||||
);
|
||||
final errors = response['errors'];
|
||||
if (errors is List && errors.isNotEmpty && response['data'] == null) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class StreamTwitch {
|
|||
final String startedAt;
|
||||
final String thumbnailUrl;
|
||||
|
||||
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
|
||||
@JsonKey(defaultValue: '')
|
||||
final String language;
|
||||
|
||||
const StreamTwitch(
|
||||
this.userId,
|
||||
this.userLogin,
|
||||
|
|
@ -24,8 +28,9 @@ class StreamTwitch {
|
|||
this.title,
|
||||
this.viewerCount,
|
||||
this.startedAt,
|
||||
this.thumbnailUrl,
|
||||
);
|
||||
this.thumbnailUrl, [
|
||||
this.language = '',
|
||||
]);
|
||||
|
||||
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$StreamTwitchFromJson(json);
|
||||
|
|
@ -38,6 +43,20 @@ class StreamsTwitch {
|
|||
|
||||
const StreamsTwitch(this.data, this.pagination);
|
||||
|
||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$StreamsTwitchFromJson(json);
|
||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
|
||||
final rawPagination = json['pagination'];
|
||||
final pagination = <String, String>{};
|
||||
if (rawPagination is Map) {
|
||||
final cursor = rawPagination['cursor'];
|
||||
if (cursor is String && cursor.isNotEmpty) {
|
||||
pagination['cursor'] = cursor;
|
||||
}
|
||||
}
|
||||
return StreamsTwitch(
|
||||
(json['data'] as List<dynamic>)
|
||||
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
|||
(json['viewer_count'] as num).toInt(),
|
||||
json['started_at'] as String,
|
||||
json['thumbnail_url'] as String,
|
||||
json['language'] as String? ?? '',
|
||||
);
|
||||
|
||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||
|
|
|
|||
50
lib/models/stream_languages.dart
Normal file
50
lib/models/stream_languages.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/// ISO 639-1 codes offered in Settings for stream language filtering.
|
||||
///
|
||||
/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form.
|
||||
class StreamLanguages {
|
||||
StreamLanguages._();
|
||||
|
||||
static const all = <({String code, String label})>[
|
||||
(code: 'de', label: 'Deutsch'),
|
||||
(code: 'en', label: 'English'),
|
||||
(code: 'es', label: 'Español'),
|
||||
(code: 'fr', label: 'Français'),
|
||||
(code: 'it', label: 'Italiano'),
|
||||
(code: 'pt', label: 'Português'),
|
||||
(code: 'pl', label: 'Polski'),
|
||||
(code: 'ru', label: 'Русский'),
|
||||
(code: 'tr', label: 'Türkçe'),
|
||||
(code: 'nl', label: 'Nederlands'),
|
||||
(code: 'sv', label: 'Svenska'),
|
||||
(code: 'no', label: 'Norsk'),
|
||||
(code: 'da', label: 'Dansk'),
|
||||
(code: 'fi', label: 'Suomi'),
|
||||
(code: 'cs', label: 'Čeština'),
|
||||
(code: 'hu', label: 'Magyar'),
|
||||
(code: 'ro', label: 'Română'),
|
||||
(code: 'uk', label: 'Українська'),
|
||||
(code: 'ja', label: '日本語'),
|
||||
(code: 'ko', label: '한국어'),
|
||||
(code: 'zh', label: '中文'),
|
||||
(code: 'ar', label: 'العربية'),
|
||||
(code: 'th', label: 'ไทย'),
|
||||
(code: 'vi', label: 'Tiếng Việt'),
|
||||
(code: 'id', label: 'Bahasa Indonesia'),
|
||||
(code: 'hi', label: 'हिन्दी'),
|
||||
(code: 'other', label: 'Other'),
|
||||
];
|
||||
|
||||
static String labelFor(String code) {
|
||||
for (final entry in all) {
|
||||
if (entry.code == code) return entry.label;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/// Helix / settings codes → GQL `Language` enum values.
|
||||
static List<String> toGqlEnums(Iterable<String> codes) => codes
|
||||
.map((c) => c.trim().toLowerCase())
|
||||
.where((c) => c.isNotEmpty)
|
||||
.map((c) => c.toUpperCase())
|
||||
.toList();
|
||||
}
|
||||
|
|
@ -44,6 +44,10 @@ abstract class ListStoreBase with Store {
|
|||
/// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
|
||||
final ScrollController? scrollController;
|
||||
|
||||
/// When true, [dispose] will dispose [scrollController]. Shared controllers
|
||||
/// from Home must not be disposed here.
|
||||
final bool ownsScrollController;
|
||||
|
||||
/// The pagination cursor for the streams.
|
||||
String? _streamsCursor;
|
||||
|
||||
|
|
@ -64,6 +68,12 @@ abstract class ListStoreBase with Store {
|
|||
_isCategoryDetailsLoading ||
|
||||
_isOfflineChannelsLoading;
|
||||
|
||||
/// Selected stream language codes from settings (lowercase ISO 639-1).
|
||||
List<String> get _languageFilter => settingsStore.streamLanguages
|
||||
.map((l) => l.trim().toLowerCase())
|
||||
.where((l) => l.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
/// The loading status for pagination.
|
||||
|
||||
/// The list of the fetched streams.
|
||||
|
|
@ -156,6 +166,7 @@ abstract class ListStoreBase with Store {
|
|||
|
||||
ReactionDisposer? _pinnedStreamsReactionDisposer;
|
||||
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
||||
ReactionDisposer? _languageFilterReactionDisposer;
|
||||
|
||||
ListStoreBase({
|
||||
required this.authStore,
|
||||
|
|
@ -167,16 +178,10 @@ abstract class ListStoreBase with Store {
|
|||
this.freeformTag,
|
||||
this.sortOrder = StreamViewerSort.viewersDesc,
|
||||
this.scrollController,
|
||||
this.ownsScrollController = false,
|
||||
}) {
|
||||
if (scrollController != null) {
|
||||
scrollController!.addListener(() {
|
||||
if (scrollController!.position.atEdge ||
|
||||
scrollController!.position.outOfRange) {
|
||||
showJumpButton = false;
|
||||
} else {
|
||||
showJumpButton = true;
|
||||
}
|
||||
});
|
||||
scrollController!.addListener(_onScroll);
|
||||
}
|
||||
|
||||
if (listType == ListType.followed) {
|
||||
|
|
@ -209,15 +214,44 @@ abstract class ListStoreBase with Store {
|
|||
_getCategoryDetails();
|
||||
}
|
||||
|
||||
// Reload directory lists when the language filter changes.
|
||||
if (listType != ListType.followed) {
|
||||
_languageFilterReactionDisposer = reaction(
|
||||
(_) => settingsStore.streamLanguages.toList(),
|
||||
(_) => refreshStreams(),
|
||||
);
|
||||
}
|
||||
|
||||
getStreams();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final controller = scrollController;
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
|
||||
final position = controller.position;
|
||||
if (position.atEdge || position.outOfRange) {
|
||||
showJumpButton = false;
|
||||
} else {
|
||||
showJumpButton = true;
|
||||
}
|
||||
|
||||
// Backup pagination trigger (in addition to SliverList near-end builds).
|
||||
if (hasMore &&
|
||||
position.pixels >= position.maxScrollExtent - 800) {
|
||||
getStreams();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the streams based on the type and current cursor.
|
||||
@action
|
||||
Future<void> getStreams() async {
|
||||
if (_isAllStreamsLoading) return;
|
||||
|
||||
_isAllStreamsLoading = true;
|
||||
|
||||
try {
|
||||
final languages = _languageFilter;
|
||||
final StreamsTwitch newStreams;
|
||||
switch (listType) {
|
||||
case ListType.followed:
|
||||
|
|
@ -227,15 +261,19 @@ abstract class ListStoreBase with Store {
|
|||
);
|
||||
break;
|
||||
case ListType.top:
|
||||
// Helix is always viewer-desc; ascending needs GQL.
|
||||
// Ascending sort needs GQL (Helix is always desc).
|
||||
if (sortOrder == StreamViewerSort.viewersAsc &&
|
||||
twitchGqlApi != null) {
|
||||
newStreams = await twitchGqlApi!.getStreams(
|
||||
sort: sortOrder,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
|
||||
newStreams = await twitchApi.getStreamsMatchingLanguages(
|
||||
languages: languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ListType.category:
|
||||
|
|
@ -244,10 +282,12 @@ abstract class ListStoreBase with Store {
|
|||
newStreams = await twitchGqlApi!.getGameStreams(
|
||||
gameId: categoryId!,
|
||||
sort: sortOrder,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getStreamsUnderCategory(
|
||||
newStreams = await twitchApi.getStreamsMatchingLanguages(
|
||||
languages: languages,
|
||||
gameId: categoryId!,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
|
|
@ -257,32 +297,60 @@ abstract class ListStoreBase with Store {
|
|||
newStreams = await twitchGqlApi!.getStreams(
|
||||
freeformTags: [freeformTag!],
|
||||
sort: sortOrder,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// GQL page-2 often returns empty `data` under IntegrityCheckFailed
|
||||
// without throwing — treat empty continuation pages as end-of-list.
|
||||
final isContinuation = _streamsCursor != null;
|
||||
if (isContinuation && newStreams.data.isEmpty) {
|
||||
_streamsCursor = null;
|
||||
_error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamsCursor == null) {
|
||||
_allStreams = newStreams.data.asObservable();
|
||||
} else {
|
||||
_allStreams.addAll(newStreams.data);
|
||||
// Dedupe in case of overlapping over-fetch pages.
|
||||
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||
_allStreams.addAll(
|
||||
newStreams.data.where((s) => !existingIds.contains(s.userId)),
|
||||
);
|
||||
}
|
||||
_streamsCursor = newStreams.pagination['cursor'];
|
||||
final next = newStreams.pagination['cursor'];
|
||||
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
|
||||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
_error = 'Unable to connect to Twitch';
|
||||
debugPrint('Streams SocketException: No internet connection');
|
||||
// Stop retry storms on failed continuations.
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Streams ApiException: $e');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
// Don't surface integrity / page-2 failures as a full-list error.
|
||||
if (_allStreams.isNotEmpty) _error = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_error = 'Something went wrong loading streams';
|
||||
debugPrint('Streams error: $e');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
} finally {
|
||||
_isAllStreamsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes viewer sort and reloads from the first page.
|
||||
@action
|
||||
|
|
@ -396,8 +464,14 @@ abstract class ListStoreBase with Store {
|
|||
void dispose() {
|
||||
_pinnedStreamsReactionDisposer?.call();
|
||||
_offlineChannelsExpansionDisposer?.call();
|
||||
_languageFilterReactionDisposer?.call();
|
||||
|
||||
scrollController?.dispose();
|
||||
if (scrollController != null) {
|
||||
scrollController!.removeListener(_onScroll);
|
||||
if (ownsScrollController) {
|
||||
scrollController!.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,13 +78,18 @@ class _StreamsListState extends State<StreamsList>
|
|||
freeformTag: widget.freeformTag,
|
||||
sortOrder: widget.sortOrder,
|
||||
scrollController: widget.scrollController ?? _scrollController,
|
||||
ownsScrollController: widget.scrollController == null && widget.showJumpButton,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// ListStore disposes the controller when it owns it.
|
||||
if (!_listStore.ownsScrollController) {
|
||||
_scrollController?.dispose();
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_listStore.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
// import removed: flutter_colorpicker
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/models/stream_languages.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/widgets/accent_color_setting.dart';
|
||||
import 'package:frosty/widgets/external_browser_setting.dart';
|
||||
// import removed: frosty/widgets/dialog.dart
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
import 'package:frosty/widgets/theme_selection_setting.dart';
|
||||
|
|
@ -34,6 +33,48 @@ class GeneralSettings extends StatelessWidget {
|
|||
value: settingsStore.showThumbnails,
|
||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||
),
|
||||
const SectionHeader('Stream languages'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
settingsStore.streamLanguages.isEmpty
|
||||
? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).'
|
||||
: 'Only streams in the selected languages appear in Top, category, and tag lists.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final entry in StreamLanguages.all)
|
||||
FilterChip(
|
||||
label: Text(entry.label),
|
||||
selected: settingsStore.streamLanguages.contains(entry.code),
|
||||
onSelected: (selected) {
|
||||
final next = [...settingsStore.streamLanguages];
|
||||
if (selected) {
|
||||
if (!next.contains(entry.code)) next.add(entry.code);
|
||||
} else {
|
||||
next.remove(entry.code);
|
||||
}
|
||||
settingsStore.streamLanguages = next;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (settingsStore.streamLanguages.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
settingsStore.streamLanguages = const <String>[],
|
||||
child: const Text('Clear language filter'),
|
||||
),
|
||||
),
|
||||
const SectionHeader('Links'),
|
||||
ExternalBrowserSetting(settingsStore: settingsStore),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ abstract class _SettingsStoreBase with Store {
|
|||
@observable
|
||||
var launchUrlExternal = defaultLaunchUrlExternal;
|
||||
|
||||
/// ISO 639-1 language codes for Top / category / tag stream lists.
|
||||
/// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account.
|
||||
static const defaultStreamLanguages = <String>['de'];
|
||||
|
||||
@JsonKey(defaultValue: defaultStreamLanguages)
|
||||
@observable
|
||||
var streamLanguages = defaultStreamLanguages;
|
||||
|
||||
@action
|
||||
void resetGeneralSettings() {
|
||||
themeType = defaultThemeType;
|
||||
|
|
@ -61,6 +69,7 @@ abstract class _SettingsStoreBase with Store {
|
|||
showThumbnails = defaultShowThumbnails;
|
||||
|
||||
launchUrlExternal = defaultLaunchUrlExternal;
|
||||
streamLanguages = defaultStreamLanguages;
|
||||
}
|
||||
|
||||
// * Video Settings
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ SettingsStore _$SettingsStoreFromJson(
|
|||
..showThumbnails = json['showThumbnails'] as bool? ?? true
|
||||
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
|
||||
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
|
||||
..streamLanguages =
|
||||
(json['streamLanguages'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
['de']
|
||||
..showVideo = json['showVideo'] as bool? ?? true
|
||||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||
|
|
@ -104,6 +109,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
|||
'showThumbnails': instance.showThumbnails,
|
||||
'largeStreamCard': instance.largeStreamCard,
|
||||
'launchUrlExternal': instance.launchUrlExternal,
|
||||
'streamLanguages': instance.streamLanguages,
|
||||
'showVideo': instance.showVideo,
|
||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||
'useTextureRendering': instance.useTextureRendering,
|
||||
|
|
@ -278,6 +284,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$streamLanguagesAtom = Atom(
|
||||
name: '_SettingsStoreBase.streamLanguages',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
List<String> get streamLanguages {
|
||||
_$streamLanguagesAtom.reportRead();
|
||||
return super.streamLanguages;
|
||||
}
|
||||
|
||||
@override
|
||||
set streamLanguages(List<String> value) {
|
||||
_$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () {
|
||||
super.streamLanguages = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$showVideoAtom = Atom(
|
||||
name: '_SettingsStoreBase.showVideo',
|
||||
context: context,
|
||||
|
|
@ -1261,6 +1285,7 @@ accentColor: ${accentColor},
|
|||
showThumbnails: ${showThumbnails},
|
||||
largeStreamCard: ${largeStreamCard},
|
||||
launchUrlExternal: ${launchUrlExternal},
|
||||
streamLanguages: ${streamLanguages},
|
||||
showVideo: ${showVideo},
|
||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||
useTextureRendering: ${useTextureRendering},
|
||||
|
|
|
|||
|
|
@ -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.3+73
|
||||
version: 5.2.4+74
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue