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`.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
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.
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue