diff --git a/DESIGN.md b/DESIGN.md index 8151e954..ba1df2ad 100644 --- a/DESIGN.md +++ b/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`. diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 148b7efa..23cec738 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -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 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 getTopStreams({ + String? cursor, + String? language, + int first = 100, + }) async { + final queryParams = { + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; + final data = await get( '/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( @@ -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( @@ -221,15 +233,82 @@ class TwitchApi extends BaseApiClient { Future 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 = { + 'game_id': gameId, + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; final data = await get('/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 getStreamsMatchingLanguages({ + required List 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 = []; + 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 getStream({required String userLogin}) async { final data = await get( diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index e6ac79fb..beb88194 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -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 getStreams({ List? freeformTags, + List? 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 getGameStreams({ required String gameId, + List? 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 = []; 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, ); } } diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 0f433ff6..8e09ffed 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -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 json) => _$StreamTwitchFromJson(json); @@ -38,6 +43,20 @@ class StreamsTwitch { const StreamsTwitch(this.data, this.pagination); - factory StreamsTwitch.fromJson(Map json) => - _$StreamsTwitchFromJson(json); + factory StreamsTwitch.fromJson(Map json) { + final rawPagination = json['pagination']; + final pagination = {}; + if (rawPagination is Map) { + final cursor = rawPagination['cursor']; + if (cursor is String && cursor.isNotEmpty) { + pagination['cursor'] = cursor; + } + } + return StreamsTwitch( + (json['data'] as List) + .map((e) => StreamTwitch.fromJson(e as Map)) + .toList(), + pagination, + ); + } } diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index fe2fe565..4d012271 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -16,6 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map 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 json) => diff --git a/lib/models/stream_languages.dart b/lib/models/stream_languages.dart new file mode 100644 index 00000000..455ccc60 --- /dev/null +++ b/lib/models/stream_languages.dart @@ -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 toGqlEnums(Iterable codes) => codes + .map((c) => c.trim().toLowerCase()) + .where((c) => c.isNotEmpty) + .map((c) => c.toUpperCase()) + .toList(); +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 64c6fb46..8dfde208 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -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 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 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,31 +297,59 @@ 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; } - - _isAllStreamsLoading = false; } /// Changes viewer sort and reloads from the first page. @@ -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(); + } + } } } diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index e784170b..e24e6812 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -78,13 +78,18 @@ class _StreamsListState extends State freeformTag: widget.freeformTag, sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, + ownsScrollController: widget.scrollController == null && widget.showJumpButton, ); } @override void dispose() { - _scrollController?.dispose(); + // ListStore disposes the controller when it owns it. + if (!_listStore.ownsScrollController) { + _scrollController?.dispose(); + } WidgetsBinding.instance.removeObserver(this); + _listStore.dispose(); super.dispose(); } diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 6b13f477..96d5f8c9 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -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 [], + child: const Text('Clear language filter'), + ), + ), const SectionHeader('Links'), ExternalBrowserSetting(settingsStore: settingsStore), ], diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 30599f99..3c2a3583 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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 = ['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 diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index a583ac94..be512a98 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -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?) + ?.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 _$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 get streamLanguages { + _$streamLanguagesAtom.reportRead(); + return super.streamLanguages; + } + + @override + set streamLanguages(List 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}, diff --git a/pubspec.yaml b/pubspec.yaml index 67d176c7..75525dc5 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.3+73 +version: 5.2.4+74 environment: sdk: ^3.9.0