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
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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: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].
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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: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 }
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
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
|
# 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue