Add freeform tag search and viewer sort for live lists.
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:
parent
7863935fff
commit
d8247843c4
15 changed files with 655 additions and 89 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
|||
return super._blockingAds;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isBlockingAds => blockingAds;
|
||||
|
||||
@override
|
||||
bool get _blockingAds => blockingAds;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
lib/screens/home/search/search_results_tags.dart
Normal file
97
lib/screens/home/search/search_results_tags.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
83
lib/screens/home/search/tag_streams.dart
Normal file
83
lib/screens/home/search/tag_streams.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 }
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
@ -64,75 +71,80 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
|||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,12 +65,23 @@ class _TopSectionState extends State<TopSection>
|
|||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
38
lib/widgets/stream_sort_button.dart
Normal file
38
lib/widgets/stream_sort_button.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue