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 - Third-party proxies
- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) - 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 ## Build
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.

View file

@ -5,14 +5,47 @@ import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/playback_access_token.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) /// Uses the Twitch web client ID (not the app's registered client ID)
/// since the GQL endpoint requires it. /// since the GQL endpoint requires it.
class TwitchGqlApi extends BaseApiClient { class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); 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 /// Browser-consistent User-Agent for playback-related requests. Twitch has
/// rejected access-token requests with non-browser UAs before /// rejected access-token requests with non-browser UAs before
/// (streamlink#6574); sending one consistent with the web client ID and /// (streamlink#6574); sending one consistent with the web client ID and
@ -140,4 +173,138 @@ class TwitchGqlApi extends BaseApiClient {
} }
return buffer.toString(); 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; return super._blockingAds;
} }
@override
bool get isBlockingAds => blockingAds;
@override @override
bool get _blockingAds => blockingAds; bool get _blockingAds => blockingAds;

View file

@ -2,8 +2,10 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.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_categories.dart';
import 'package:frosty/screens/home/search/search_results_channels.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/search/search_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -31,6 +33,7 @@ class _SearchState extends State<Search> {
late final _searchStore = SearchStore( late final _searchStore = SearchStore(
authStore: context.read<AuthStore>(), authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(), twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
); );
@override @override
@ -171,6 +174,20 @@ class _SearchState extends State<Search> {
), ),
), ),
SearchResultsCategories(searchStore: _searchStore), 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 // Add padding for bottom navigation bar
const SliverBottomPadding(), const SliverBottomPadding(),
], ],
@ -204,7 +221,7 @@ class _SearchState extends State<Search> {
onChanged: _searchStore.onSearchTextChanged, onChanged: _searchStore.onSearchTextChanged,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Search channels or categories', hintText: 'Search channels, categories, or tags',
suffixIcon: suffixIcon:
_searchStore.textFieldFocusNode.hasFocus || _searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty _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:flutter/widgets.dart';
import 'package:frosty/apis/twitch_api.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/category.dart';
import 'package:frosty/models/channel.dart'; import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -17,6 +18,8 @@ abstract class SearchStoreBase with Store {
final TwitchApi twitchApi; final TwitchApi twitchApi;
final TwitchGqlApi twitchGqlApi;
final textEditingController = TextEditingController(); final textEditingController = TextEditingController();
final textFieldFocusNode = FocusNode(); final textFieldFocusNode = FocusNode();
@ -40,7 +43,14 @@ abstract class SearchStoreBase with Store {
@readonly @readonly
ObservableFuture<CategoriesTwitch?>? _categoryFuture; 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(); init();
} }
@ -75,6 +85,7 @@ abstract class SearchStoreBase with Store {
_isSearching = false; _isSearching = false;
_channelFuture = null; _channelFuture = null;
_categoryFuture = null; _categoryFuture = null;
_tagFuture = null;
return; return;
} }
@ -127,6 +138,8 @@ abstract class SearchStoreBase with Store {
} }
return categories; return categories;
}).asObservable(); }).asObservable();
_tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable();
} }
/// Find a specific channel provided the [query]. /// 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( late final _$initAsyncAction = AsyncAction(
'SearchStoreBase.init', 'SearchStoreBase.init',
context: context, 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:flutter/widgets.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.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/category.dart';
import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/stream.dart'; import 'package:frosty/models/stream.dart';
@ -23,12 +24,22 @@ abstract class ListStoreBase with Store {
/// Twitch API service class for making requests. /// Twitch API service class for making requests.
final TwitchApi twitchApi; 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. /// The type of list that this store is handling.
final ListType listType; final ListType listType;
/// The category id to use when fetching streams if the [listType] is [ListType.category]. /// The category id to use when fetching streams if the [listType] is [ListType.category].
final String? categoryId; 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). /// 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. /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
final ScrollController? scrollController; final ScrollController? scrollController;
@ -151,7 +162,10 @@ abstract class ListStoreBase with Store {
required this.settingsStore, required this.settingsStore,
required this.twitchApi, required this.twitchApi,
required this.listType, required this.listType,
this.twitchGqlApi,
this.categoryId, this.categoryId,
this.freeformTag,
this.sortOrder = StreamViewerSort.viewersDesc,
this.scrollController, this.scrollController,
}) { }) {
if (scrollController != null) { if (scrollController != null) {
@ -213,11 +227,36 @@ abstract class ListStoreBase with Store {
); );
break; break;
case ListType.top: 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; break;
case ListType.category: case ListType.category:
newStreams = await twitchApi.getStreamsUnderCategory( if (sortOrder == StreamViewerSort.viewersAsc &&
gameId: categoryId!, 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, cursor: _streamsCursor,
); );
break; break;
@ -245,6 +284,14 @@ abstract class ListStoreBase with Store {
_isAllStreamsLoading = false; _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 @action
Future<void> getPinnedStreams() async { Future<void> getPinnedStreams() async {
if (settingsStore.pinnedChannelIds.isEmpty) { 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.followed] is the list of streams that the user is following.
/// [ListType.top] is the list of top streams. /// [ListType.top] is the list of top streams.
/// [ListType.category] is the list of streams under a category. /// [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', name: 'ListStoreBase.offlineChannels',
)).value; )).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( late final _$_allStreamsAtom = Atom(
name: 'ListStoreBase._allStreams', name: 'ListStoreBase._allStreams',
context: context, context: context,
@ -302,6 +320,16 @@ mixin _$ListStore on ListStoreBase, Store {
return _$getStreamsAsyncAction.run(() => super.getStreams()); 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( late final _$getPinnedStreamsAsyncAction = AsyncAction(
'ListStoreBase.getPinnedStreams', 'ListStoreBase.getPinnedStreams',
context: context, context: context,
@ -349,6 +377,7 @@ mixin _$ListStore on ListStoreBase, Store {
@override @override
String toString() { String toString() {
return ''' return '''
sortOrder: ${sortOrder},
showJumpButton: ${showJumpButton}, showJumpButton: ${showJumpButton},
isOfflineChannelsExpanded: ${isOfflineChannelsExpanded}, isOfflineChannelsExpanded: ${isOfflineChannelsExpanded},
hasMore: ${hasMore}, hasMore: ${hasMore},

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.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/models/stream.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart'; import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
@ -25,6 +26,12 @@ class StreamsList extends StatefulWidget {
final String? categoryId; 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. /// The scroll controller to use for scroll to top functionality.
final ScrollController? scrollController; final ScrollController? scrollController;
@ -34,6 +41,8 @@ class StreamsList extends StatefulWidget {
super.key, super.key,
required this.listType, required this.listType,
this.categoryId, this.categoryId,
this.freeformTag,
this.sortOrder = StreamViewerSort.viewersDesc,
this.scrollController, this.scrollController,
this.showJumpButton = false, this.showJumpButton = false,
}); });
@ -63,8 +72,11 @@ class _StreamsListState extends State<StreamsList>
authStore: context.read<AuthStore>(), authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(), settingsStore: context.read<SettingsStore>(),
twitchApi: context.read<TwitchApi>(), twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
listType: widget.listType, listType: widget.listType,
categoryId: widget.categoryId, categoryId: widget.categoryId,
freeformTag: widget.freeformTag,
sortOrder: widget.sortOrder,
scrollController: widget.scrollController ?? _scrollController, scrollController: widget.scrollController ?? _scrollController,
); );
} }
@ -95,6 +107,8 @@ class _StreamsListState extends State<StreamsList>
? kToolbarHeight ? kToolbarHeight
: widget.listType == ListType.category : widget.listType == ListType.category
? kToolbarHeight + 122 ? kToolbarHeight + 122
: widget.listType == ListType.tag
? kToolbarHeight
: 0.0; : 0.0;
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding; final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
@ -209,9 +223,12 @@ class _StreamsListState extends State<StreamsList>
); );
} else { } else {
statusWidget = AlertMessage( statusWidget = AlertMessage(
message: widget.listType == ListType.followed message: switch (widget.listType) {
? 'No followed streams' ListType.followed => 'No followed streams',
: 'No top streams', ListType.tag => 'No streams with this tag',
ListType.category => 'No streams in this category',
ListType.top => 'No top streams',
},
vertical: true, vertical: true,
); );
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/screens/home/stream_list/stream_list_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/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/blurred_container.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart'; import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:frosty/widgets/stream_sort_button.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
/// A widget that displays a list of streams under the provided [categoryId]. /// 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> { class _CategoryStreamsState extends State<CategoryStreams> {
var _sortOrder = StreamViewerSort.viewersDesc;
late final _listStore = ListStore( late final _listStore = ListStore(
listType: ListType.category, listType: ListType.category,
categoryId: widget.categoryId, categoryId: widget.categoryId,
authStore: context.read<AuthStore>(), authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(), twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
settingsStore: context.read<SettingsStore>(), settingsStore: context.read<SettingsStore>(),
); );
@ -46,8 +51,10 @@ class _CategoryStreamsState extends State<CategoryStreams> {
// Stream list content behind the pinned elements // Stream list content behind the pinned elements
Positioned.fill( Positioned.fill(
child: StreamsList( child: StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.category, listType: ListType.category,
categoryId: widget.categoryId, categoryId: widget.categoryId,
sortOrder: _sortOrder,
showJumpButton: true, showJumpButton: true,
), ),
), ),
@ -64,75 +71,80 @@ class _CategoryStreamsState extends State<CategoryStreams> {
right: MediaQuery.of(context).padding.right, right: MediaQuery.of(context).padding.right,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// App bar section // App bar section
SizedBox( SizedBox(
height: kToolbarHeight, height: kToolbarHeight,
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
tooltip: 'Back', tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded), icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop, 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,
), ),
], child: Row(
), spacing: 12,
), children: [
// Category card section const SizedBox(
Observer( width: 80,
builder: (_) { child: AspectRatio(
if (_listStore.categoryDetails != null) { aspectRatio: 3 / 4,
return _TransparentCategoryCard( child: SkeletonLoader(
category: _listStore.categoryDetails!, borderRadius: kCardBorderRadius,
); ),
} else { ),
// Skeleton loader for category card ),
return Padding( Expanded(
padding: const EdgeInsets.only( child: Column(
bottom: 16, crossAxisAlignment: CrossAxisAlignment.start,
left: 16, spacing: 8,
right: 16, children: [
), SkeletonLoader(
child: Row( height: 20,
spacing: 12, width: double.infinity,
children: [ borderRadius: BorderRadius.circular(4),
const SizedBox(
width: 80,
child: AspectRatio(
aspectRatio: 3 / 4,
child: SkeletonLoader(
borderRadius: kCardBorderRadius,
), ),
), 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),
),
],
),
),
],
),
);
}
},
),
],
),
), ),
), ),
], ],

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; 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/home_store.dart';
import 'package:frosty/screens/home/stream_list/stream_list_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/stream_list/streams_list.dart';
import 'package:frosty/screens/home/top/categories/categories.dart'; import 'package:frosty/screens/home/top/categories/categories.dart';
import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/stream_sort_button.dart';
class TopSection extends StatefulWidget { class TopSection extends StatefulWidget {
final HomeStore homeStore; final HomeStore homeStore;
@ -17,17 +19,21 @@ class TopSection extends StatefulWidget {
class _TopSectionState extends State<TopSection> class _TopSectionState extends State<TopSection>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final _tabBarController = TabController(length: 2, vsync: this); late final _tabBarController = TabController(length: 2, vsync: this);
var _sortOrder = StreamViewerSort.viewersDesc;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabBarController.addListener( _tabBarController.addListener(() {
() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, widget.homeStore.topSectionCurrentIndex = _tabBarController.index;
); setState(() {});
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showSort = _tabBarController.index == 0;
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
@ -35,7 +41,9 @@ class _TopSectionState extends State<TopSection>
controller: _tabBarController, controller: _tabBarController,
children: [ children: [
StreamsList( StreamsList(
key: ValueKey(_sortOrder),
listType: ListType.top, listType: ListType.top,
sortOrder: _sortOrder,
scrollController: scrollController:
widget.homeStore.topSectionScrollControllers[0], widget.homeStore.topSectionScrollControllers[0],
), ),
@ -57,12 +65,23 @@ class _TopSectionState extends State<TopSection>
left: MediaQuery.of(context).padding.left, left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right, right: MediaQuery.of(context).padding.right,
), ),
child: TabBar( child: Row(
isScrollable: true, children: [
controller: _tabBarController, Expanded(
tabs: const [ child: TabBar(
Tab(child: Text('Streams')), isScrollable: true,
Tab(child: Text('Categories')), controller: _tabBarController,
tabs: const [
Tab(child: Text('Streams')),
Tab(child: Text('Categories')),
],
),
),
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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 5.2.1+71 version: 5.2.2+72
environment: environment:
sdk: ^3.9.0 sdk: ^3.9.0