Fix tag pagination and persist viewer sort.

Load tag (and language) directories via Helix with client-side tag matching so scroll can continue past the GQL integrity wall. Re-sort lists locally after each fetch and store streamViewerSort in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-10-01 11:41:25 +02:00
parent 9d392d8d6a
commit 1ad2a90076
12 changed files with 214 additions and 113 deletions

View file

@ -49,11 +49,9 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge
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.
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable)
- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity
## Build

View file

@ -248,29 +248,42 @@ class TwitchApi extends BaseApiClient {
return StreamsTwitch.fromJson(data);
}
/// Fetches Helix stream pages until [minCount] streams match [languages]
/// (or the directory is exhausted). Preserves Helix viewer order.
/// Fetches Helix directory pages until enough streams match [languages] and
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
///
/// Used when the user selected more than one language — Helix only accepts a
/// single `language` query param.
Future<StreamsTwitch> getStreamsMatchingLanguages({
required List<String> languages,
/// Helix is always viewer-desc and supports at most one `language` query
/// param; tags are filtered client-side. Prefer this over GQL for pagination
/// (GQL page-2 requires a non-bot Client-Integrity token).
Future<StreamsTwitch> getDirectoryStreams({
List<String> languages = const [],
String? gameId,
String? freeformTag,
String? cursor,
int minCount = 40,
int maxPages = 8,
int minCount = 50,
int maxPages = 12,
}) async {
final wanted = languages
final wantedLangs = 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);
final tagNeedle = freeformTag?.trim().toLowerCase();
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
bool matches(StreamTwitch stream) {
if (wantedLangs.isNotEmpty &&
!wantedLangs.contains(stream.language.toLowerCase())) {
return false;
}
if (hasTag) {
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
if (!hit) return false;
}
return true;
}
if (wanted.length == 1) {
final lang = wanted.first;
// Fast path: no tag filter and 0–1 languages → one Helix call.
if (!hasTag && wantedLangs.length <= 1) {
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
return gameId == null
? getTopStreams(cursor: cursor, language: lang)
: getStreamsUnderCategory(
@ -280,6 +293,11 @@ class TwitchApi extends BaseApiClient {
);
}
// When filtering by tag and exactly one language, still pass language to
// Helix so each page is denser in matches.
final helixLanguage =
wantedLangs.length == 1 ? wantedLangs.first : null;
final matched = <StreamTwitch>[];
String? pageCursor = cursor;
String? nextCursor;
@ -287,14 +305,16 @@ class TwitchApi extends BaseApiClient {
while (matched.length < minCount && pages < maxPages) {
final page = gameId == null
? await getTopStreams(cursor: pageCursor)
: await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor);
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
: await getStreamsUnderCategory(
gameId: gameId,
cursor: pageCursor,
language: helixLanguage,
);
pages++;
nextCursor = page.pagination['cursor'];
for (final stream in page.data) {
if (wanted.contains(stream.language.toLowerCase())) {
matched.add(stream);
}
if (matches(stream)) matched.add(stream);
}
if (nextCursor == null || page.data.isEmpty) {
nextCursor = null;
@ -309,6 +329,22 @@ class TwitchApi extends BaseApiClient {
);
}
/// Deprecated name — use [getDirectoryStreams].
Future<StreamsTwitch> getStreamsMatchingLanguages({
required List<String> languages,
String? gameId,
String? cursor,
int minCount = 40,
int maxPages = 8,
}) =>
getDirectoryStreams(
languages: languages,
gameId: gameId,
cursor: cursor,
minCount: minCount,
maxPages: maxPages,
);
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
Future<StreamTwitch> getStream({required String userLogin}) async {
final data = await get<JsonMap>(

View file

@ -340,6 +340,7 @@ class TwitchGqlApi extends BaseApiClient {
// Helix-style placeholder so existing thumbnail widgets keep working.
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
language,
const [],
);
}
}

View file

@ -19,6 +19,10 @@ class StreamTwitch {
@JsonKey(defaultValue: '')
final String language;
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
@JsonKey(defaultValue: <String>[])
final List<String> tags;
const StreamTwitch(
this.userId,
this.userLogin,
@ -30,6 +34,7 @@ class StreamTwitch {
this.startedAt,
this.thumbnailUrl, [
this.language = '',
this.tags = const [],
]);
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>

View file

@ -17,6 +17,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
json['started_at'] as String,
json['thumbnail_url'] as String,
json['language'] as String? ?? '',
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
);
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>

View file

@ -1,28 +1,24 @@
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/stream_sort_button.dart';
import 'package:provider/provider.dart';
/// Streams matching a freeform tag (search → tap tag).
class TagStreams extends StatefulWidget {
class TagStreams extends StatelessWidget {
static const routeName = 'TagStreams';
final String tagName;
const TagStreams({super.key, required this.tagName});
@override
State<TagStreams> createState() => _TagStreamsState();
}
class _TagStreamsState extends State<TagStreams> {
var _sortOrder = StreamViewerSort.viewersDesc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStore = context.watch<SettingsStore>();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
@ -31,12 +27,13 @@ class _TagStreamsState extends State<TagStreams> {
body: Stack(
children: [
Positioned.fill(
child: StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.tag,
freeformTag: widget.tagName,
sortOrder: _sortOrder,
showJumpButton: true,
child: Observer(
builder: (_) => StreamsList(
listType: ListType.tag,
freeformTag: tagName,
sortOrder: settingsStore.streamViewerSort,
showJumpButton: true,
),
),
),
Positioned(
@ -61,15 +58,18 @@ class _TagStreamsState extends State<TagStreams> {
),
Expanded(
child: Text(
widget.tagName,
tagName,
style: theme.textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
StreamSortButton(
sortOrder: _sortOrder,
onChanged: (order) => setState(() => _sortOrder = order),
Observer(
builder: (_) => StreamSortButton(
sortOrder: settingsStore.streamViewerSort,
onChanged: (order) =>
settingsStore.streamViewerSort = order,
),
),
],
),

View file

@ -214,11 +214,18 @@ abstract class ListStoreBase with Store {
_getCategoryDetails();
}
// Reload directory lists when the language filter changes.
// Reload directory lists when the language filter or sort changes.
if (listType != ListType.followed) {
sortOrder = settingsStore.streamViewerSort;
_languageFilterReactionDisposer = reaction(
(_) => settingsStore.streamLanguages.toList(),
(_) => refreshStreams(),
(_) => [
...settingsStore.streamLanguages,
settingsStore.streamViewerSort,
],
(_) {
sortOrder = settingsStore.streamViewerSort;
refreshStreams();
},
);
}
@ -252,6 +259,14 @@ abstract class ListStoreBase with Store {
try {
final languages = _languageFilter;
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
// Asc needs a deeper Helix scan so client-side re-sort can surface
// lower-viewer streams (Helix itself is always viewer-desc).
final minCount = isAsc ? 80 : 50;
final maxPages = isAsc
? (_streamsCursor == null ? 20 : 10)
: (_streamsCursor == null ? 12 : 8);
final StreamsTwitch newStreams;
switch (listType) {
case ListType.followed:
@ -261,50 +276,36 @@ abstract class ListStoreBase with Store {
);
break;
case ListType.top:
// 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.getStreamsMatchingLanguages(
languages: languages,
cursor: _streamsCursor,
);
}
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
case ListType.category:
if (sortOrder == StreamViewerSort.viewersAsc &&
twitchGqlApi != null) {
newStreams = await twitchGqlApi!.getGameStreams(
gameId: categoryId!,
sort: sortOrder,
languages: languages.isEmpty ? null : languages,
cursor: _streamsCursor,
);
} else {
newStreams = await twitchApi.getStreamsMatchingLanguages(
languages: languages,
gameId: categoryId!,
cursor: _streamsCursor,
);
}
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
gameId: categoryId!,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
case ListType.tag:
newStreams = await twitchGqlApi!.getStreams(
freeformTags: [freeformTag!],
sort: sortOrder,
languages: languages.isEmpty ? null : languages,
// Helix + client tag filter (GQL freeformTags can't paginate without
// a non-bot Client-Integrity token).
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
freeformTag: freeformTag,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
}
// GQL page-2 often returns empty `data` under IntegrityCheckFailed
// without throwing — treat empty continuation pages as end-of-list.
// Empty continuation ⇒ end of directory.
final isContinuation = _streamsCursor != null;
if (isContinuation && newStreams.data.isEmpty) {
_streamsCursor = null;
@ -315,7 +316,6 @@ abstract class ListStoreBase with Store {
if (_streamsCursor == null) {
_allStreams = newStreams.data.asObservable();
} else {
// 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)),
@ -324,11 +324,11 @@ abstract class ListStoreBase with Store {
final next = newStreams.pagination['cursor'];
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
_applyViewerSort();
_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;
}
@ -337,7 +337,6 @@ abstract class ListStoreBase with Store {
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) {
@ -352,12 +351,19 @@ abstract class ListStoreBase with Store {
}
}
/// Changes viewer sort and reloads from the first page.
void _applyViewerSort() {
final sorted = _allStreams.toList();
sorted.sort((a, b) {
final cmp = a.viewerCount.compareTo(b.viewerCount);
return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp;
});
_allStreams = sorted.asObservable();
}
/// Persists viewer sort and reloads via the settings reaction.
@action
Future<void> setSortOrder(StreamViewerSort order) async {
if (sortOrder == order) return;
sortOrder = order;
await refreshStreams();
settingsStore.streamViewerSort = order;
}
@action

View file

@ -27,7 +27,7 @@ class CategoryStreams extends StatefulWidget {
}
class _CategoryStreamsState extends State<CategoryStreams> {
var _sortOrder = StreamViewerSort.viewersDesc;
late final _settingsStore = context.read<SettingsStore>();
late final _listStore = ListStore(
listType: ListType.category,
@ -35,7 +35,8 @@ class _CategoryStreamsState extends State<CategoryStreams> {
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
settingsStore: context.read<SettingsStore>(),
settingsStore: _settingsStore,
sortOrder: _settingsStore.streamViewerSort,
);
@override
@ -50,12 +51,13 @@ class _CategoryStreamsState extends State<CategoryStreams> {
children: [
// Stream list content behind the pinned elements
Positioned.fill(
child: StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.category,
categoryId: widget.categoryId,
sortOrder: _sortOrder,
showJumpButton: true,
child: Observer(
builder: (_) => StreamsList(
listType: ListType.category,
categoryId: widget.categoryId,
sortOrder: _settingsStore.streamViewerSort,
showJumpButton: true,
),
),
),
// Single blurred background spanning app bar and category card
@ -84,10 +86,12 @@ class _CategoryStreamsState extends State<CategoryStreams> {
onPressed: Navigator.of(context).pop,
),
const Spacer(),
StreamSortButton(
sortOrder: _sortOrder,
onChanged: (order) =>
setState(() => _sortOrder = order),
Observer(
builder: (_) => StreamSortButton(
sortOrder: _settingsStore.streamViewerSort,
onChanged: (order) =>
_settingsStore.streamViewerSort = order,
),
),
],
),

View file

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/home/top/categories/categories.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/stream_sort_button.dart';
import 'package:provider/provider.dart';
class TopSection extends StatefulWidget {
final HomeStore homeStore;
@ -19,7 +21,6 @@ class TopSection extends StatefulWidget {
class _TopSectionState extends State<TopSection>
with SingleTickerProviderStateMixin {
late final _tabBarController = TabController(length: 2, vsync: this);
var _sortOrder = StreamViewerSort.viewersDesc;
@override
void initState() {
@ -32,6 +33,7 @@ class _TopSectionState extends State<TopSection>
@override
Widget build(BuildContext context) {
final settingsStore = context.watch<SettingsStore>();
final showSort = _tabBarController.index == 0;
return Stack(
@ -40,12 +42,13 @@ class _TopSectionState extends State<TopSection>
child: TabBarView(
controller: _tabBarController,
children: [
StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.top,
sortOrder: _sortOrder,
scrollController:
widget.homeStore.topSectionScrollControllers[0],
Observer(
builder: (_) => StreamsList(
listType: ListType.top,
sortOrder: settingsStore.streamViewerSort,
scrollController:
widget.homeStore.topSectionScrollControllers[0],
),
),
Categories(
scrollController:
@ -78,9 +81,12 @@ class _TopSectionState extends State<TopSection>
),
),
if (showSort)
StreamSortButton(
sortOrder: _sortOrder,
onChanged: (order) => setState(() => _sortOrder = order),
Observer(
builder: (_) => StreamSortButton(
sortOrder: settingsStore.streamViewerSort,
onChanged: (order) =>
settingsStore.streamViewerSort = order,
),
),
],
),

View file

@ -1,3 +1,4 @@
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:mobx/mobx.dart';
@ -60,6 +61,16 @@ abstract class _SettingsStoreBase with Store {
@observable
var streamLanguages = defaultStreamLanguages;
/// Persistent viewer sort for Top / category / tag directories.
static const defaultStreamViewerSort = StreamViewerSort.viewersDesc;
@JsonKey(
defaultValue: defaultStreamViewerSort,
unknownEnumValue: StreamViewerSort.viewersDesc,
)
@observable
var streamViewerSort = defaultStreamViewerSort;
@action
void resetGeneralSettings() {
themeType = defaultThemeType;
@ -70,6 +81,7 @@ abstract class _SettingsStoreBase with Store {
launchUrlExternal = defaultLaunchUrlExternal;
streamLanguages = defaultStreamLanguages;
streamViewerSort = defaultStreamViewerSort;
}
// * Video Settings

View file

@ -25,6 +25,13 @@ SettingsStore _$SettingsStoreFromJson(
?.map((e) => e as String)
.toList() ??
['de']
..streamViewerSort =
$enumDecodeNullable(
_$StreamViewerSortEnumMap,
json['streamViewerSort'],
unknownValue: StreamViewerSort.viewersDesc,
) ??
StreamViewerSort.viewersDesc
..showVideo = json['showVideo'] as bool? ?? true
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
@ -110,6 +117,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
'largeStreamCard': instance.largeStreamCard,
'launchUrlExternal': instance.launchUrlExternal,
'streamLanguages': instance.streamLanguages,
'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!,
'showVideo': instance.showVideo,
'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering,
@ -165,6 +173,11 @@ const _$ThemeTypeEnumMap = {
ThemeType.dark: 'dark',
};
const _$StreamViewerSortEnumMap = {
StreamViewerSort.viewersDesc: 'viewersDesc',
StreamViewerSort.viewersAsc: 'viewersAsc',
};
const _$TimestampTypeEnumMap = {
TimestampType.disabled: 'disabled',
TimestampType.twelve: 'twelve',
@ -302,6 +315,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$streamViewerSortAtom = Atom(
name: '_SettingsStoreBase.streamViewerSort',
context: context,
);
@override
StreamViewerSort get streamViewerSort {
_$streamViewerSortAtom.reportRead();
return super.streamViewerSort;
}
@override
set streamViewerSort(StreamViewerSort value) {
_$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () {
super.streamViewerSort = value;
});
}
late final _$showVideoAtom = Atom(
name: '_SettingsStoreBase.showVideo',
context: context,
@ -1286,6 +1317,7 @@ showThumbnails: ${showThumbnails},
largeStreamCard: ${largeStreamCard},
launchUrlExternal: ${launchUrlExternal},
streamLanguages: ${streamLanguages},
streamViewerSort: ${streamViewerSort},
showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality},
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
# 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.4+74
version: 5.2.5+75
environment:
sdk: ^3.9.0