Add freeform tag search and viewer sort for live lists.
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 24s

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 <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-09-30 23:22:56 +02:00
parent 7863935fff
commit d8247843c4
15 changed files with 655 additions and 89 deletions

View file

@ -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`.

View file

@ -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 = <String, String>{
'Client-ID': twitchGqlClientId,
'Content-Type': 'application/json',
};
Future<JsonMap> _gql(Map<String, Object?> body) async {
final response = await post<JsonMap>(
'/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<List<String>> 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<String>()
.toList();
}
/// Live streams matching optional [freeformTags], sorted by viewer count.
Future<StreamsTwitch> getStreams({
List<String>? 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<StreamsTwitch> 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 = <StreamTwitch>[];
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',
);
}
}

View file

@ -67,9 +67,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
return super._blockingAds;
}
@override
bool get isBlockingAds => blockingAds;
@override
bool get _blockingAds => blockingAds;

View file

@ -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<Search> {
late final _searchStore = SearchStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
);
@override
@ -171,6 +174,20 @@ class _SearchState extends State<Search> {
),
),
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<Search> {
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

View file

@ -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<String> tags = future.result as List<String>? ?? 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),
),
),
);
}
}

View file

@ -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<CategoriesTwitch?>? _categoryFuture;
SearchStoreBase({required this.authStore, required this.twitchApi}) {
@readonly
ObservableFuture<List<String>>? _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].

View file

@ -109,6 +109,26 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$_tagFutureAtom = Atom(
name: 'SearchStoreBase._tagFuture',
context: context,
);
ObservableFuture<List<String>>? get tagFuture {
_$_tagFutureAtom.reportRead();
return super._tagFuture;
}
@override
ObservableFuture<List<String>>? get _tagFuture => tagFuture;
@override
set _tagFuture(ObservableFuture<List<String>>? value) {
_$_tagFutureAtom.reportWrite(value, super._tagFuture, () {
super._tagFuture = value;
});
}
late final _$initAsyncAction = AsyncAction(
'SearchStoreBase.init',
context: context,

View file

@ -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<TagStreams> createState() => _TagStreamsState();
}
class _TagStreamsState extends State<TagStreams> {
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),
),
],
),
),
),
),
],
),
);
}
}

View file

@ -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,13 +227,38 @@ abstract class ListStoreBase with Store {
);
break;
case ListType.top:
// 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:
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<void> setSortOrder(StreamViewerSort order) async {
if (sortOrder == order) return;
sortOrder = order;
await refreshStreams();
}
@action
Future<void> 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 }

View file

@ -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<void> 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},

View file

@ -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<StreamsList>
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
listType: widget.listType,
categoryId: widget.categoryId,
freeformTag: widget.freeformTag,
sortOrder: widget.sortOrder,
scrollController: widget.scrollController ?? _scrollController,
);
}
@ -95,6 +107,8 @@ class _StreamsListState extends State<StreamsList>
? 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<StreamsList>
);
} 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,
);
}

View file

@ -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<CategoryStreams> {
var _sortOrder = StreamViewerSort.viewersDesc;
late final _listStore = ListStore(
listType: ListType.category,
categoryId: widget.categoryId,
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
settingsStore: context.read<SettingsStore>(),
);
@ -46,8 +51,10 @@ class _CategoryStreamsState extends State<CategoryStreams> {
// Stream list content behind the pinned elements
Positioned.fill(
child: StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.category,
categoryId: widget.categoryId,
sortOrder: _sortOrder,
showJumpButton: true,
),
),
@ -76,6 +83,12 @@ class _CategoryStreamsState extends State<CategoryStreams> {
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
const Spacer(),
StreamSortButton(
sortOrder: _sortOrder,
onChanged: (order) =>
setState(() => _sortOrder = order),
),
],
),
),
@ -108,8 +121,7 @@ class _CategoryStreamsState extends State<CategoryStreams> {
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
SkeletonLoader(

View file

@ -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<TopSection>
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<TopSection>
controller: _tabBarController,
children: [
StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.top,
sortOrder: _sortOrder,
scrollController:
widget.homeStore.topSectionScrollControllers[0],
),
@ -57,6 +65,9 @@ class _TopSectionState extends State<TopSection>
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: Row(
children: [
Expanded(
child: TabBar(
isScrollable: true,
controller: _tabBarController,
@ -66,6 +77,14 @@ class _TopSectionState extends State<TopSection>
],
),
),
if (showSort)
StreamSortButton(
sortOrder: _sortOrder,
onChanged: (order) => setState(() => _sortOrder = order),
),
],
),
),
),
],
);

View file

@ -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<StreamViewerSort> onChanged;
const StreamSortButton({
super.key,
required this.sortOrder,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
return PopupMenuButton<StreamViewerSort>(
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'),
),
],
);
}
}

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.1+71
version: 5.2.2+72
environment:
sdk: ^3.9.0