diff --git a/DESIGN.md b/DESIGN.md index ba1df2ad..25adaf1d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 23cec738..a1a7e4c2 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -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 getStreamsMatchingLanguages({ - required List 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 getDirectoryStreams({ + List 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 = []; 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 getStreamsMatchingLanguages({ + required List 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 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 beb88194..1f6f228f 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -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 [], ); } } diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 8e09ffed..34a35c60 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -19,6 +19,10 @@ class StreamTwitch { @JsonKey(defaultValue: '') final String language; + /// Freeform tags from Helix (e.g. `Deutsch`, `English`). + @JsonKey(defaultValue: []) + final List 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 json) => diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 4d012271..95f9bff3 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -17,6 +17,7 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( json['started_at'] as String, json['thumbnail_url'] as String, json['language'] as String? ?? '', + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart index 52b28194..c78c9051 100644 --- a/lib/screens/home/search/tag_streams.dart +++ b/lib/screens/home/search/tag_streams.dart @@ -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 createState() => _TagStreamsState(); -} - -class _TagStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; - @override Widget build(BuildContext context) { final theme = Theme.of(context); + final settingsStore = context.watch(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, @@ -31,12 +27,13 @@ class _TagStreamsState extends State { 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 { ), 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, + ), ), ], ), diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 8dfde208..a1631949 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -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 setSortOrder(StreamViewerSort order) async { - if (sortOrder == order) return; - sortOrder = order; - await refreshStreams(); + settingsStore.streamViewerSort = order; } @action diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index 69d165f1..d5a1edf0 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -27,7 +27,7 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; + late final _settingsStore = context.read(); late final _listStore = ListStore( listType: ListType.category, @@ -35,7 +35,8 @@ class _CategoryStreamsState extends State { authStore: context.read(), twitchApi: context.read(), twitchGqlApi: context.read(), - settingsStore: context.read(), + settingsStore: _settingsStore, + sortOrder: _settingsStore.streamViewerSort, ); @override @@ -50,12 +51,13 @@ class _CategoryStreamsState extends State { 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 { 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, + ), ), ], ), diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 467ead4d..5f4d51a1 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -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 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 @override Widget build(BuildContext context) { + final settingsStore = context.watch(); final showSort = _tabBarController.index == 0; return Stack( @@ -40,12 +42,13 @@ class _TopSectionState extends State 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 ), ), if (showSort) - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: settingsStore.streamViewerSort, + onChanged: (order) => + settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 3c2a3583..031b2c53 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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 diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index be512a98..797941cf 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -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 _$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}, diff --git a/pubspec.yaml b/pubspec.yaml index 75525dc5..45974ec4 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.4+74 +version: 5.2.5+75 environment: sdk: ^3.9.0