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

@ -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`. 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 ## Build
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.

View file

@ -175,11 +175,23 @@ class TwitchApi extends BaseApiClient {
} }
} }
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. /// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
Future<StreamsTwitch> getTopStreams({String? cursor}) async { ///
/// [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>( final data = await get<JsonMap>(
'/streams', '/streams',
queryParameters: cursor != null ? {'after': cursor} : null, queryParameters: queryParams,
); );
return StreamsTwitch.fromJson(data); return StreamsTwitch.fromJson(data);
@ -190,7 +202,7 @@ class TwitchApi extends BaseApiClient {
required String id, required String id,
String? cursor, String? cursor,
}) async { }) async {
final queryParams = {'user_id': id}; final queryParams = {'user_id': id, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor; if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>( final data = await get<JsonMap>(
@ -206,7 +218,7 @@ class TwitchApi extends BaseApiClient {
required String userId, required String userId,
String? cursor, String? cursor,
}) async { }) async {
final queryParams = {'user_id': userId, 'first': '20'}; final queryParams = {'user_id': userId, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor; if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>( final data = await get<JsonMap>(
@ -221,15 +233,82 @@ class TwitchApi extends BaseApiClient {
Future<StreamsTwitch> getStreamsUnderCategory({ Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId, required String gameId,
String? cursor, String? cursor,
String? language,
int first = 100,
}) async { }) async {
final queryParams = {'game_id': gameId}; final queryParams = <String, dynamic>{
if (cursor != null) queryParams['after'] = cursor; '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); final data = await get<JsonMap>('/streams', queryParameters: queryParams);
return StreamsTwitch.fromJson(data); 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]. /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
Future<StreamTwitch> getStream({required String userLogin}) async { Future<StreamTwitch> getStream({required String userLogin}) async {
final data = await get<JsonMap>( final data = await get<JsonMap>(

View file

@ -38,10 +38,16 @@ class TwitchGqlApi extends BaseApiClient {
headers: _gqlHeaders, headers: _gqlHeaders,
); );
final errors = response['errors']; final errors = response['errors'];
if (errors is List && errors.isNotEmpty && response['data'] == null) { if (errors is List && errors.isNotEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ?? final data = response['data'];
'Twitch GQL error'; final dataEmpty = data == null ||
throw ApiException(message); (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; return response;
} }
@ -205,28 +211,42 @@ class TwitchGqlApi extends BaseApiClient {
.toList(); .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({ Future<StreamsTwitch> getStreams({
List<String>? freeformTags, List<String>? freeformTags,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc, StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor, String? cursor,
int first = 30, int first = 30,
}) async { }) 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({ final response = await _gql({
'operationName': 'FrostyTopStreams', 'operationName': 'FrostyTopStreams',
'query': 'query':
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort) {' r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags }) {' r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
r' edges { cursor node {' r' edges { cursor node {'
r' id title viewersCount createdAt' r' id title viewersCount createdAt language'
r' game { id name }' r' game { id name }'
r' broadcaster { id login displayName }' r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } }', r' } } pageInfo { hasNextPage } } }',
'variables': { 'variables': {
'first': first, 'first': cappedFirst,
'after': cursor, 'after': cursor,
'tags': freeformTags, 'tags': freeformTags,
'sort': sort.gqlValue, 'sort': sort.gqlValue,
'langs': gqlLangs,
}, },
}); });
@ -235,28 +255,38 @@ class TwitchGqlApi extends BaseApiClient {
return _streamsFromConnection(connection); 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({ Future<StreamsTwitch> getGameStreams({
required String gameId, required String gameId,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc, StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor, String? cursor,
int first = 30, int first = 30,
}) async { }) 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({ final response = await _gql({
'operationName': 'FrostyGameStreams', 'operationName': 'FrostyGameStreams',
'query': 'query':
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort) {' 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 }) {' r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
r' edges { cursor node {' r' edges { cursor node {'
r' id title viewersCount createdAt' r' id title viewersCount createdAt language'
r' game { id name }' r' game { id name }'
r' broadcaster { id login displayName }' r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } } }', r' } } pageInfo { hasNextPage } } } }',
'variables': { 'variables': {
'id': gameId, 'id': gameId,
'first': first, 'first': cappedFirst,
'after': cursor, 'after': cursor,
'sort': sort.gqlValue, 'sort': sort.gqlValue,
'langs': gqlLangs,
}, },
}); });
@ -270,7 +300,8 @@ class TwitchGqlApi extends BaseApiClient {
StreamsTwitch _streamsFromConnection(JsonMap connection) { StreamsTwitch _streamsFromConnection(JsonMap connection) {
final edges = (connection['edges'] as List?) ?? const []; final edges = (connection['edges'] as List?) ?? const [];
final pageInfo = connection['pageInfo'] as JsonMap?; 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>[]; final streams = <StreamTwitch>[];
String? nextCursor; String? nextCursor;
@ -285,7 +316,9 @@ class TwitchGqlApi extends BaseApiClient {
return StreamsTwitch( return StreamsTwitch(
streams, 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 broadcaster = node['broadcaster'] as JsonMap? ?? const {};
final game = node['game'] as JsonMap? ?? const {}; final game = node['game'] as JsonMap? ?? const {};
final login = broadcaster['login'] as String? ?? ''; final login = broadcaster['login'] as String? ?? '';
final language = (node['language'] as String? ?? '').toLowerCase();
return StreamTwitch( return StreamTwitch(
broadcaster['id'] as String? ?? '', broadcaster['id'] as String? ?? '',
@ -305,6 +339,7 @@ class TwitchGqlApi extends BaseApiClient {
node['createdAt'] as String? ?? '', node['createdAt'] as String? ?? '',
// Helix-style placeholder so existing thumbnail widgets keep working. // Helix-style placeholder so existing thumbnail widgets keep working.
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
language,
); );
} }
} }

View file

@ -15,6 +15,10 @@ class StreamTwitch {
final String startedAt; final String startedAt;
final String thumbnailUrl; final String thumbnailUrl;
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
@JsonKey(defaultValue: '')
final String language;
const StreamTwitch( const StreamTwitch(
this.userId, this.userId,
this.userLogin, this.userLogin,
@ -24,8 +28,9 @@ class StreamTwitch {
this.title, this.title,
this.viewerCount, this.viewerCount,
this.startedAt, this.startedAt,
this.thumbnailUrl, this.thumbnailUrl, [
); this.language = '',
]);
factory StreamTwitch.fromJson(Map<String, dynamic> json) => factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamTwitchFromJson(json); _$StreamTwitchFromJson(json);
@ -38,6 +43,20 @@ class StreamsTwitch {
const StreamsTwitch(this.data, this.pagination); const StreamsTwitch(this.data, this.pagination);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) => factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
_$StreamsTwitchFromJson(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,
);
}
} }

View file

@ -16,6 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
(json['viewer_count'] as num).toInt(), (json['viewer_count'] as num).toInt(),
json['started_at'] as String, json['started_at'] as String,
json['thumbnail_url'] as String, json['thumbnail_url'] as String,
json['language'] as String? ?? '',
); );
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) => StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>

View 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();
}

View file

@ -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. /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
final ScrollController? scrollController; 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. /// The pagination cursor for the streams.
String? _streamsCursor; String? _streamsCursor;
@ -64,6 +68,12 @@ abstract class ListStoreBase with Store {
_isCategoryDetailsLoading || _isCategoryDetailsLoading ||
_isOfflineChannelsLoading; _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 loading status for pagination.
/// The list of the fetched streams. /// The list of the fetched streams.
@ -156,6 +166,7 @@ abstract class ListStoreBase with Store {
ReactionDisposer? _pinnedStreamsReactionDisposer; ReactionDisposer? _pinnedStreamsReactionDisposer;
ReactionDisposer? _offlineChannelsExpansionDisposer; ReactionDisposer? _offlineChannelsExpansionDisposer;
ReactionDisposer? _languageFilterReactionDisposer;
ListStoreBase({ ListStoreBase({
required this.authStore, required this.authStore,
@ -167,16 +178,10 @@ abstract class ListStoreBase with Store {
this.freeformTag, this.freeformTag,
this.sortOrder = StreamViewerSort.viewersDesc, this.sortOrder = StreamViewerSort.viewersDesc,
this.scrollController, this.scrollController,
this.ownsScrollController = false,
}) { }) {
if (scrollController != null) { if (scrollController != null) {
scrollController!.addListener(() { scrollController!.addListener(_onScroll);
if (scrollController!.position.atEdge ||
scrollController!.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
} }
if (listType == ListType.followed) { if (listType == ListType.followed) {
@ -209,15 +214,44 @@ abstract class ListStoreBase with Store {
_getCategoryDetails(); _getCategoryDetails();
} }
// Reload directory lists when the language filter changes.
if (listType != ListType.followed) {
_languageFilterReactionDisposer = reaction(
(_) => settingsStore.streamLanguages.toList(),
(_) => refreshStreams(),
);
}
getStreams(); 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. /// Fetches the streams based on the type and current cursor.
@action @action
Future<void> getStreams() async { Future<void> getStreams() async {
if (_isAllStreamsLoading) return;
_isAllStreamsLoading = true; _isAllStreamsLoading = true;
try { try {
final languages = _languageFilter;
final StreamsTwitch newStreams; final StreamsTwitch newStreams;
switch (listType) { switch (listType) {
case ListType.followed: case ListType.followed:
@ -227,15 +261,19 @@ abstract class ListStoreBase with Store {
); );
break; break;
case ListType.top: case ListType.top:
// Helix is always viewer-desc; ascending needs GQL. // Ascending sort needs GQL (Helix is always desc).
if (sortOrder == StreamViewerSort.viewersAsc && if (sortOrder == StreamViewerSort.viewersAsc &&
twitchGqlApi != null) { twitchGqlApi != null) {
newStreams = await twitchGqlApi!.getStreams( newStreams = await twitchGqlApi!.getStreams(
sort: sortOrder, sort: sortOrder,
languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor, cursor: _streamsCursor,
); );
} else { } else {
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); newStreams = await twitchApi.getStreamsMatchingLanguages(
languages: languages,
cursor: _streamsCursor,
);
} }
break; break;
case ListType.category: case ListType.category:
@ -244,10 +282,12 @@ abstract class ListStoreBase with Store {
newStreams = await twitchGqlApi!.getGameStreams( newStreams = await twitchGqlApi!.getGameStreams(
gameId: categoryId!, gameId: categoryId!,
sort: sortOrder, sort: sortOrder,
languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor, cursor: _streamsCursor,
); );
} else { } else {
newStreams = await twitchApi.getStreamsUnderCategory( newStreams = await twitchApi.getStreamsMatchingLanguages(
languages: languages,
gameId: categoryId!, gameId: categoryId!,
cursor: _streamsCursor, cursor: _streamsCursor,
); );
@ -257,31 +297,59 @@ abstract class ListStoreBase with Store {
newStreams = await twitchGqlApi!.getStreams( newStreams = await twitchGqlApi!.getStreams(
freeformTags: [freeformTag!], freeformTags: [freeformTag!],
sort: sortOrder, sort: sortOrder,
languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor, cursor: _streamsCursor,
); );
break; 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) { if (_streamsCursor == null) {
_allStreams = newStreams.data.asObservable(); _allStreams = newStreams.data.asObservable();
} else { } 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; _error = null;
} on SocketException { } on SocketException {
_error = 'Unable to connect to Twitch'; _error = 'Unable to connect to Twitch';
debugPrint('Streams SocketException: No internet connection'); debugPrint('Streams SocketException: No internet connection');
// Stop retry storms on failed continuations.
if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null;
}
} on ApiException catch (e) { } on ApiException catch (e) {
_error = e.message; _error = e.message;
debugPrint('Streams ApiException: $e'); 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) { } catch (e) {
_error = 'Something went wrong loading streams'; _error = 'Something went wrong loading streams';
debugPrint('Streams error: $e'); debugPrint('Streams error: $e');
if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null;
_error = null;
}
} finally {
_isAllStreamsLoading = false;
} }
_isAllStreamsLoading = false;
} }
/// Changes viewer sort and reloads from the first page. /// Changes viewer sort and reloads from the first page.
@ -396,8 +464,14 @@ abstract class ListStoreBase with Store {
void dispose() { void dispose() {
_pinnedStreamsReactionDisposer?.call(); _pinnedStreamsReactionDisposer?.call();
_offlineChannelsExpansionDisposer?.call(); _offlineChannelsExpansionDisposer?.call();
_languageFilterReactionDisposer?.call();
scrollController?.dispose(); if (scrollController != null) {
scrollController!.removeListener(_onScroll);
if (ownsScrollController) {
scrollController!.dispose();
}
}
} }
} }

View file

@ -78,13 +78,18 @@ class _StreamsListState extends State<StreamsList>
freeformTag: widget.freeformTag, freeformTag: widget.freeformTag,
sortOrder: widget.sortOrder, sortOrder: widget.sortOrder,
scrollController: widget.scrollController ?? _scrollController, scrollController: widget.scrollController ?? _scrollController,
ownsScrollController: widget.scrollController == null && widget.showJumpButton,
); );
} }
@override @override
void dispose() { void dispose() {
_scrollController?.dispose(); // ListStore disposes the controller when it owns it.
if (!_listStore.ownsScrollController) {
_scrollController?.dispose();
}
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_listStore.dispose();
super.dispose(); super.dispose();
} }

View file

@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// import removed: flutter_colorpicker
import 'package:flutter_mobx/flutter_mobx.dart'; 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/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/accent_color_setting.dart'; import 'package:frosty/widgets/accent_color_setting.dart';
import 'package:frosty/widgets/external_browser_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/section_header.dart';
import 'package:frosty/widgets/settings_page_layout.dart'; import 'package:frosty/widgets/settings_page_layout.dart';
import 'package:frosty/widgets/theme_selection_setting.dart'; import 'package:frosty/widgets/theme_selection_setting.dart';
@ -34,6 +33,48 @@ class GeneralSettings extends StatelessWidget {
value: settingsStore.showThumbnails, value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue, 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'), const SectionHeader('Links'),
ExternalBrowserSetting(settingsStore: settingsStore), ExternalBrowserSetting(settingsStore: settingsStore),
], ],

View file

@ -52,6 +52,14 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var launchUrlExternal = defaultLaunchUrlExternal; 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 @action
void resetGeneralSettings() { void resetGeneralSettings() {
themeType = defaultThemeType; themeType = defaultThemeType;
@ -61,6 +69,7 @@ abstract class _SettingsStoreBase with Store {
showThumbnails = defaultShowThumbnails; showThumbnails = defaultShowThumbnails;
launchUrlExternal = defaultLaunchUrlExternal; launchUrlExternal = defaultLaunchUrlExternal;
streamLanguages = defaultStreamLanguages;
} }
// * Video Settings // * Video Settings

View file

@ -20,6 +20,11 @@ SettingsStore _$SettingsStoreFromJson(
..showThumbnails = json['showThumbnails'] as bool? ?? true ..showThumbnails = json['showThumbnails'] as bool? ?? true
..largeStreamCard = json['largeStreamCard'] as bool? ?? false ..largeStreamCard = json['largeStreamCard'] as bool? ?? false
..launchUrlExternal = json['launchUrlExternal'] 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 ..showVideo = json['showVideo'] as bool? ?? true
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true ..useTextureRendering = json['useTextureRendering'] as bool? ?? true
@ -104,6 +109,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showThumbnails': instance.showThumbnails, 'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard, 'largeStreamCard': instance.largeStreamCard,
'launchUrlExternal': instance.launchUrlExternal, 'launchUrlExternal': instance.launchUrlExternal,
'streamLanguages': instance.streamLanguages,
'showVideo': instance.showVideo, 'showVideo': instance.showVideo,
'defaultToHighestQuality': instance.defaultToHighestQuality, 'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering, '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( late final _$showVideoAtom = Atom(
name: '_SettingsStoreBase.showVideo', name: '_SettingsStoreBase.showVideo',
context: context, context: context,
@ -1261,6 +1285,7 @@ accentColor: ${accentColor},
showThumbnails: ${showThumbnails}, showThumbnails: ${showThumbnails},
largeStreamCard: ${largeStreamCard}, largeStreamCard: ${largeStreamCard},
launchUrlExternal: ${launchUrlExternal}, launchUrlExternal: ${launchUrlExternal},
streamLanguages: ${streamLanguages},
showVideo: ${showVideo}, showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality}, defaultToHighestQuality: ${defaultToHighestQuality},
useTextureRendering: ${useTextureRendering}, useTextureRendering: ${useTextureRendering},

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 # 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 # 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. # 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: environment:
sdk: ^3.9.0 sdk: ^3.9.0