From d8247843c471eae771fcbcbc6ea372cc222b9a90 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 23:22:56 +0200 Subject: [PATCH] Add freeform tag search and viewer sort for live lists. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helix cannot filter by tags or sort ascending; use Twitch GQL for Search→Tags→streams and Most/Fewest viewers on Top, category, and tag screens. Co-authored-by: Cursor --- DESIGN.md | 9 + lib/apis/twitch_gql_api.dart | 169 +++++++++++++++++- .../channel/video/native_video_store.g.dart | 3 - lib/screens/home/search/search.dart | 19 +- .../home/search/search_results_tags.dart | 97 ++++++++++ lib/screens/home/search/search_store.dart | 15 +- lib/screens/home/search/search_store.g.dart | 20 +++ lib/screens/home/search/tag_streams.dart | 83 +++++++++ .../home/stream_list/stream_list_store.dart | 56 +++++- .../home/stream_list/stream_list_store.g.dart | 29 +++ .../home/stream_list/streams_list.dart | 23 ++- .../home/top/categories/category_streams.dart | 144 ++++++++------- lib/screens/home/top/top.dart | 37 +++- lib/widgets/stream_sort_button.dart | 38 ++++ pubspec.yaml | 2 +- 15 files changed, 655 insertions(+), 89 deletions(-) create mode 100644 lib/screens/home/search/search_results_tags.dart create mode 100644 lib/screens/home/search/tag_streams.dart create mode 100644 lib/widgets/stream_sort_button.dart diff --git a/DESIGN.md b/DESIGN.md index 882b4114..8151e954 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,6 +36,15 @@ Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player - Third-party proxies - HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) +## Tags + viewer sort (v5.2.2+) + +Helix cannot filter by freeform tags or sort ascending. GQL covers both: + +- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`) +- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton` + +Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. + ## Build Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index f72e7250..e6ac79fb 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -5,14 +5,47 @@ import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; +import 'package:frosty/models/stream.dart'; -/// Twitch GQL API client for fetching playback access tokens. +/// Viewer sort for GQL directory / tag stream lists. +enum StreamViewerSort { + viewersDesc, + viewersAsc; + + String get gqlValue => switch (this) { + StreamViewerSort.viewersDesc => 'VIEWER_COUNT', + StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC', + }; +} + +/// Twitch GQL API client for playback tokens and directory queries +/// (freeform tags + sorted streams) that Helix does not expose. /// /// Uses the Twitch web client ID (not the app's registered client ID) /// since the GQL endpoint requires it. class TwitchGqlApi extends BaseApiClient { TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); + static const _gqlHeaders = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + }; + + Future _gql(Map body) async { + final response = await post( + '/gql', + data: body, + 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); + } + return response; + } + /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -140,4 +173,138 @@ class TwitchGqlApi extends BaseApiClient { } return buffer.toString(); } + + /// Freeform tag name suggestions for search-as-you-type. + Future> searchFreeformTags({ + required String query, + int first = 20, + }) async { + if (query.trim().isEmpty) return const []; + + final response = await _gql({ + 'operationName': 'SearchFreeformTags', + 'query': + r'query SearchFreeformTags($query: String!, $first: Int) {' + r' searchFreeformTags(userQuery: $query, first: $first) {' + r' edges { node { tagName } } } }', + 'variables': {'query': query, 'first': first}, + }); + + final edges = + (((response['data'] as JsonMap?)?['searchFreeformTags'] + as JsonMap?)?['edges'] + as List?) ?? + const []; + + return edges + .map((edge) { + final node = (edge as JsonMap)['node'] as JsonMap?; + return node?['tagName'] as String?; + }) + .whereType() + .toList(); + } + + /// Live streams matching optional [freeformTags], sorted by viewer count. + Future getStreams({ + List? freeformTags, + StreamViewerSort sort = StreamViewerSort.viewersDesc, + String? cursor, + int first = 30, + }) async { + 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' edges { cursor node {' + r' id title viewersCount createdAt' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } }', + 'variables': { + 'first': first, + 'after': cursor, + 'tags': freeformTags, + 'sort': sort.gqlValue, + }, + }); + + final connection = + (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; + return _streamsFromConnection(connection); + } + + /// Live streams under a category/game, with viewer sort (Helix has no asc). + Future getGameStreams({ + required String gameId, + StreamViewerSort sort = StreamViewerSort.viewersDesc, + String? cursor, + int first = 30, + }) async { + 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' edges { cursor node {' + r' id title viewersCount createdAt' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } } }', + 'variables': { + 'id': gameId, + 'first': first, + 'after': cursor, + 'sort': sort.gqlValue, + }, + }); + + final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; + if (game == null) { + return const StreamsTwitch([], {}); + } + return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); + } + + StreamsTwitch _streamsFromConnection(JsonMap connection) { + final edges = (connection['edges'] as List?) ?? const []; + final pageInfo = connection['pageInfo'] as JsonMap?; + final hasNext = pageInfo?['hasNextPage'] as bool? ?? false; + + final streams = []; + String? nextCursor; + + for (final edge in edges) { + final edgeMap = edge as JsonMap; + nextCursor = edgeMap['cursor'] as String?; + final node = edgeMap['node'] as JsonMap?; + if (node == null) continue; + streams.add(_streamFromNode(node)); + } + + return StreamsTwitch( + streams, + hasNext && nextCursor != null ? {'cursor': nextCursor} : const {}, + ); + } + + StreamTwitch _streamFromNode(JsonMap node) { + final broadcaster = node['broadcaster'] as JsonMap? ?? const {}; + final game = node['game'] as JsonMap? ?? const {}; + final login = broadcaster['login'] as String? ?? ''; + + return StreamTwitch( + broadcaster['id'] as String? ?? '', + login, + broadcaster['displayName'] as String? ?? login, + game['id'] as String? ?? '', + game['name'] as String? ?? '', + node['title'] as String? ?? '', + (node['viewersCount'] as num?)?.toInt() ?? 0, + 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', + ); + } } diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index 71fadd4f..afce5344 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -67,9 +67,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { return super._blockingAds; } - @override - bool get isBlockingAds => blockingAds; - @override bool get _blockingAds => blockingAds; diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index c5cdb719..c2fb3ab8 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -2,8 +2,10 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/home/search/search_results_categories.dart'; import 'package:frosty/screens/home/search/search_results_channels.dart'; +import 'package:frosty/screens/home/search/search_results_tags.dart'; import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -31,6 +33,7 @@ class _SearchState extends State { late final _searchStore = SearchStore( authStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), ); @override @@ -171,6 +174,20 @@ class _SearchState extends State { ), ), SearchResultsCategories(searchStore: _searchStore), + SliverToBoxAdapter( + child: Builder( + builder: (context) => SectionHeader( + 'Tags', + padding: EdgeInsets.fromLTRB( + 16 + MediaQuery.of(context).padding.left, + 8, + 16 + MediaQuery.of(context).padding.right, + 8, + ), + ), + ), + ), + SearchResultsTags(searchStore: _searchStore), // Add padding for bottom navigation bar const SliverBottomPadding(), ], @@ -204,7 +221,7 @@ class _SearchState extends State { onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), - hintText: 'Search channels or categories', + hintText: 'Search channels, categories, or tags', suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty diff --git a/lib/screens/home/search/search_results_tags.dart b/lib/screens/home/search/search_results_tags.dart new file mode 100644 index 00000000..4a445385 --- /dev/null +++ b/lib/screens/home/search/search_results_tags.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/home/search/search_store.dart'; +import 'package:frosty/screens/home/search/tag_streams.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:mobx/mobx.dart'; + +class SearchResultsTags extends StatelessWidget { + final SearchStore searchStore; + + const SearchResultsTags({super.key, required this.searchStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final future = searchStore.tagFuture; + + if (future == null) { + if (searchStore.isSearching) { + return _tagSkeletons(); + } + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + switch (future.status) { + case FutureStatus.pending: + return _tagSkeletons(); + case FutureStatus.rejected: + return const SliverToBoxAdapter( + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'Unable to load tags', + vertical: true, + ), + ), + ); + case FutureStatus.fulfilled: + final List tags = future.result as List? ?? const []; + + if (tags.isEmpty) { + return const SliverToBoxAdapter( + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'No matching tags', + vertical: true, + ), + ), + ); + } + + return SliverList.builder( + itemCount: tags.length, + itemBuilder: (context, index) { + final tag = tags[index]; + return ListTile( + leading: const Icon(Icons.tag_rounded), + title: Text(tag), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings( + name: TagStreams.routeName, + ), + builder: (context) => TagStreams(tagName: tag), + ), + ); + }, + ); + }, + ); + } + }, + ); + } + + Widget _tagSkeletons() { + return SliverList.builder( + itemCount: 4, + itemBuilder: (context, index) => ListTile( + leading: SkeletonLoader( + width: 24, + height: 24, + borderRadius: BorderRadius.circular(4), + ), + title: SkeletonLoader( + height: 16, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + } +} diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index e2d0509e..5969a495 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -17,6 +18,8 @@ abstract class SearchStoreBase with Store { final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; + final textEditingController = TextEditingController(); final textFieldFocusNode = FocusNode(); @@ -40,7 +43,14 @@ abstract class SearchStoreBase with Store { @readonly ObservableFuture? _categoryFuture; - SearchStoreBase({required this.authStore, required this.twitchApi}) { + @readonly + ObservableFuture>? _tagFuture; + + SearchStoreBase({ + required this.authStore, + required this.twitchApi, + required this.twitchGqlApi, + }) { init(); } @@ -75,6 +85,7 @@ abstract class SearchStoreBase with Store { _isSearching = false; _channelFuture = null; _categoryFuture = null; + _tagFuture = null; return; } @@ -127,6 +138,8 @@ abstract class SearchStoreBase with Store { } return categories; }).asObservable(); + + _tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable(); } /// Find a specific channel provided the [query]. diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index ace3f885..ff03473e 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -109,6 +109,26 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } + late final _$_tagFutureAtom = Atom( + name: 'SearchStoreBase._tagFuture', + context: context, + ); + + ObservableFuture>? get tagFuture { + _$_tagFutureAtom.reportRead(); + return super._tagFuture; + } + + @override + ObservableFuture>? get _tagFuture => tagFuture; + + @override + set _tagFuture(ObservableFuture>? value) { + _$_tagFutureAtom.reportWrite(value, super._tagFuture, () { + super._tagFuture = value; + }); + } + late final _$initAsyncAction = AsyncAction( 'SearchStoreBase.init', context: context, diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart new file mode 100644 index 00000000..52b28194 --- /dev/null +++ b/lib/screens/home/search/tag_streams.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.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/widgets/blurred_container.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; + +/// Streams matching a freeform tag (search → tap tag). +class TagStreams extends StatefulWidget { + 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); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + extendBody: true, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + Positioned.fill( + child: StreamsList( + key: ValueKey(_sortOrder), + listType: ListType.tag, + freeformTag: widget.tagName, + sortOrder: _sortOrder, + showJumpButton: true, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: BlurredContainer( + gradientDirection: GradientDirection.up, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + left: MediaQuery.of(context).padding.left, + right: MediaQuery.of(context).padding.right, + ), + child: SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), + Expanded( + child: Text( + widget.tagName, + style: theme.textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => setState(() => _sortOrder = order), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 94efe2f1..64c6fb46 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/stream.dart'; @@ -23,12 +24,22 @@ abstract class ListStoreBase with Store { /// Twitch API service class for making requests. final TwitchApi twitchApi; + /// GQL client for tag streams and ascending viewer sort (Helix has neither). + final TwitchGqlApi? twitchGqlApi; + /// The type of list that this store is handling. final ListType listType; /// The category id to use when fetching streams if the [listType] is [ListType.category]. final String? categoryId; + /// Freeform tag name when [listType] is [ListType.tag]. + final String? freeformTag; + + /// Viewer sort for top / category / tag lists. + @observable + StreamViewerSort sortOrder; + /// The scroll controller used for handling scroll to top (if provided). /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. final ScrollController? scrollController; @@ -151,7 +162,10 @@ abstract class ListStoreBase with Store { required this.settingsStore, required this.twitchApi, required this.listType, + this.twitchGqlApi, this.categoryId, + this.freeformTag, + this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, }) { if (scrollController != null) { @@ -213,11 +227,36 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + // Helix is always viewer-desc; ascending needs GQL. + if (sortOrder == StreamViewerSort.viewersAsc && + twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getStreams( + sort: sortOrder, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + } break; case ListType.category: - newStreams = await twitchApi.getStreamsUnderCategory( - gameId: categoryId!, + if (sortOrder == StreamViewerSort.viewersAsc && + twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getGameStreams( + gameId: categoryId!, + sort: sortOrder, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getStreamsUnderCategory( + gameId: categoryId!, + cursor: _streamsCursor, + ); + } + break; + case ListType.tag: + newStreams = await twitchGqlApi!.getStreams( + freeformTags: [freeformTag!], + sort: sortOrder, cursor: _streamsCursor, ); break; @@ -245,6 +284,14 @@ abstract class ListStoreBase with Store { _isAllStreamsLoading = false; } + /// Changes viewer sort and reloads from the first page. + @action + Future setSortOrder(StreamViewerSort order) async { + if (sortOrder == order) return; + sortOrder = order; + await refreshStreams(); + } + @action Future getPinnedStreams() async { if (settingsStore.pinnedChannelIds.isEmpty) { @@ -359,4 +406,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -enum ListType { followed, top, category } +/// [ListType.tag] is the list of streams matching a freeform tag (GQL). +enum ListType { followed, top, category, tag } diff --git a/lib/screens/home/stream_list/stream_list_store.g.dart b/lib/screens/home/stream_list/stream_list_store.g.dart index 7fa78463..3ae70481 100644 --- a/lib/screens/home/stream_list/stream_list_store.g.dart +++ b/lib/screens/home/stream_list/stream_list_store.g.dart @@ -56,6 +56,24 @@ mixin _$ListStore on ListStoreBase, Store { name: 'ListStoreBase.offlineChannels', )).value; + late final _$sortOrderAtom = Atom( + name: 'ListStoreBase.sortOrder', + context: context, + ); + + @override + StreamViewerSort get sortOrder { + _$sortOrderAtom.reportRead(); + return super.sortOrder; + } + + @override + set sortOrder(StreamViewerSort value) { + _$sortOrderAtom.reportWrite(value, super.sortOrder, () { + super.sortOrder = value; + }); + } + late final _$_allStreamsAtom = Atom( name: 'ListStoreBase._allStreams', context: context, @@ -302,6 +320,16 @@ mixin _$ListStore on ListStoreBase, Store { return _$getStreamsAsyncAction.run(() => super.getStreams()); } + late final _$setSortOrderAsyncAction = AsyncAction( + 'ListStoreBase.setSortOrder', + context: context, + ); + + @override + Future setSortOrder(StreamViewerSort order) { + return _$setSortOrderAsyncAction.run(() => super.setSortOrder(order)); + } + late final _$getPinnedStreamsAsyncAction = AsyncAction( 'ListStoreBase.getPinnedStreams', context: context, @@ -349,6 +377,7 @@ mixin _$ListStore on ListStoreBase, Store { @override String toString() { return ''' +sortOrder: ${sortOrder}, showJumpButton: ${showJumpButton}, isOfflineChannelsExpanded: ${isOfflineChannelsExpanded}, hasMore: ${hasMore}, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index ac97721d..e784170b 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/offline_channel_card.dart'; @@ -25,6 +26,12 @@ class StreamsList extends StatefulWidget { final String? categoryId; + /// Freeform tag when [listType] is [ListType.tag]. + final String? freeformTag; + + /// Viewer sort for top / category / tag lists. + final StreamViewerSort sortOrder; + /// The scroll controller to use for scroll to top functionality. final ScrollController? scrollController; @@ -34,6 +41,8 @@ class StreamsList extends StatefulWidget { super.key, required this.listType, this.categoryId, + this.freeformTag, + this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, this.showJumpButton = false, }); @@ -63,8 +72,11 @@ class _StreamsListState extends State authStore: context.read(), settingsStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), listType: widget.listType, categoryId: widget.categoryId, + freeformTag: widget.freeformTag, + sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, ); } @@ -95,6 +107,8 @@ class _StreamsListState extends State ? kToolbarHeight : widget.listType == ListType.category ? kToolbarHeight + 122 + : widget.listType == ListType.tag + ? kToolbarHeight : 0.0; final topPadding = MediaQuery.of(context).padding.top + extraTopPadding; @@ -209,9 +223,12 @@ class _StreamsListState extends State ); } else { statusWidget = AlertMessage( - message: widget.listType == ListType.followed - ? 'No followed streams' - : 'No top streams', + message: switch (widget.listType) { + ListType.followed => 'No followed streams', + ListType.tag => 'No streams with this tag', + ListType.category => 'No streams in this category', + ListType.top => 'No top streams', + }, vertical: true, ); } diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index fd1fc0c2..69d165f1 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; @@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; import 'package:provider/provider.dart'; /// A widget that displays a list of streams under the provided [categoryId]. @@ -25,11 +27,14 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { + var _sortOrder = StreamViewerSort.viewersDesc; + late final _listStore = ListStore( listType: ListType.category, categoryId: widget.categoryId, authStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), settingsStore: context.read(), ); @@ -46,8 +51,10 @@ class _CategoryStreamsState extends State { // Stream list content behind the pinned elements Positioned.fill( child: StreamsList( + key: ValueKey(_sortOrder), listType: ListType.category, categoryId: widget.categoryId, + sortOrder: _sortOrder, showJumpButton: true, ), ), @@ -64,75 +71,80 @@ class _CategoryStreamsState extends State { right: MediaQuery.of(context).padding.right, ), child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // App bar section - SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, + mainAxisSize: MainAxisSize.min, + children: [ + // App bar section + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), + const Spacer(), + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => + setState(() => _sortOrder = order), + ), + ], + ), + ), + // Category card section + Observer( + builder: (_) { + if (_listStore.categoryDetails != null) { + return _TransparentCategoryCard( + category: _listStore.categoryDetails!, + ); + } else { + // Skeleton loader for category card + return Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, ), - ], - ), - ), - // Category card section - Observer( - builder: (_) { - if (_listStore.categoryDetails != null) { - return _TransparentCategoryCard( - category: _listStore.categoryDetails!, - ); - } else { - // Skeleton loader for category card - return Padding( - padding: const EdgeInsets.only( - bottom: 16, - left: 16, - right: 16, - ), - child: Row( - spacing: 12, - children: [ - const SizedBox( - width: 80, - child: AspectRatio( - aspectRatio: 3 / 4, - child: SkeletonLoader( - borderRadius: kCardBorderRadius, + child: Row( + spacing: 12, + children: [ + const SizedBox( + width: 80, + child: AspectRatio( + aspectRatio: 3 / 4, + child: SkeletonLoader( + borderRadius: kCardBorderRadius, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + SkeletonLoader( + height: 20, + width: double.infinity, + borderRadius: BorderRadius.circular(4), ), - ), + SkeletonLoader( + height: 16, + width: 120, + borderRadius: BorderRadius.circular(4), + ), + ], ), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 8, - children: [ - SkeletonLoader( - height: 20, - width: double.infinity, - borderRadius: BorderRadius.circular(4), - ), - SkeletonLoader( - height: 16, - width: 120, - borderRadius: BorderRadius.circular(4), - ), - ], - ), - ), - ], - ), - ); - } - }, - ), - ], - ), + ), + ], + ), + ); + } + }, + ), + ], + ), ), ), ], diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 8fb8c9a5..467ead4d 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.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/widgets/blurred_container.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -17,17 +19,21 @@ 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() { super.initState(); - _tabBarController.addListener( - () => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, - ); + _tabBarController.addListener(() { + widget.homeStore.topSectionCurrentIndex = _tabBarController.index; + setState(() {}); + }); } @override Widget build(BuildContext context) { + final showSort = _tabBarController.index == 0; + return Stack( children: [ Positioned.fill( @@ -35,7 +41,9 @@ class _TopSectionState extends State controller: _tabBarController, children: [ StreamsList( + key: ValueKey(_sortOrder), listType: ListType.top, + sortOrder: _sortOrder, scrollController: widget.homeStore.topSectionScrollControllers[0], ), @@ -57,12 +65,23 @@ class _TopSectionState extends State left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right, ), - child: TabBar( - isScrollable: true, - controller: _tabBarController, - tabs: const [ - Tab(child: Text('Streams')), - Tab(child: Text('Categories')), + child: Row( + children: [ + Expanded( + child: TabBar( + isScrollable: true, + controller: _tabBarController, + tabs: const [ + Tab(child: Text('Streams')), + Tab(child: Text('Categories')), + ], + ), + ), + if (showSort) + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => setState(() => _sortOrder = order), + ), ], ), ), diff --git a/lib/widgets/stream_sort_button.dart b/lib/widgets/stream_sort_button.dart new file mode 100644 index 00000000..883fc363 --- /dev/null +++ b/lib/widgets/stream_sort_button.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; + +/// Toggles viewer-count sort between descending (default) and ascending. +class StreamSortButton extends StatelessWidget { + final StreamViewerSort sortOrder; + final ValueChanged onChanged; + + const StreamSortButton({ + super.key, + required this.sortOrder, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final isAsc = sortOrder == StreamViewerSort.viewersAsc; + + return PopupMenuButton( + tooltip: 'Sort by viewers', + icon: Icon( + isAsc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, + ), + initialValue: sortOrder, + onSelected: onChanged, + itemBuilder: (context) => [ + const PopupMenuItem( + value: StreamViewerSort.viewersDesc, + child: Text('Most viewers'), + ), + const PopupMenuItem( + value: StreamViewerSort.viewersAsc, + child: Text('Fewest viewers'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c5f510e7..76941826 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.1+71 +version: 5.2.2+72 environment: sdk: ^3.9.0