Fix tag pagination and persist viewer sort.
Load tag (and language) directories via Helix with client-side tag matching so scroll can continue past the GQL integrity wall. Re-sort lists locally after each fetch and store streamViewerSort in Settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9d392d8d6a
commit
47150d0ab3
12 changed files with 214 additions and 113 deletions
|
|
@ -49,11 +49,9 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge
|
||||||
|
|
||||||
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
|
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
|
||||||
|
|
||||||
- One language: Helix `language=` (paginates cleanly, `first=100`)
|
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
|
||||||
- Several languages: Helix over-fetch + client filter (still paginated)
|
- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable)
|
||||||
- Tags / ascending sort: GQL with `languages: [Language!]`; page-2 often hits Twitch integrity — treated as end-of-list after the first page of 30
|
- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity
|
||||||
|
|
||||||
Also fixed infinite scroll: larger Helix pages, defensive cursors, scroll near-end load-more, and stop disposing shared Home scroll controllers.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,29 +248,42 @@ class TwitchApi extends BaseApiClient {
|
||||||
return StreamsTwitch.fromJson(data);
|
return StreamsTwitch.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches Helix stream pages until [minCount] streams match [languages]
|
/// Fetches Helix directory pages until enough streams match [languages] and
|
||||||
/// (or the directory is exhausted). Preserves Helix viewer order.
|
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||||||
///
|
///
|
||||||
/// Used when the user selected more than one language — Helix only accepts a
|
/// Helix is always viewer-desc and supports at most one `language` query
|
||||||
/// single `language` query param.
|
/// param; tags are filtered client-side. Prefer this over GQL for pagination
|
||||||
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
/// (GQL page-2 requires a non-bot Client-Integrity token).
|
||||||
required List<String> languages,
|
Future<StreamsTwitch> getDirectoryStreams({
|
||||||
|
List<String> languages = const [],
|
||||||
String? gameId,
|
String? gameId,
|
||||||
|
String? freeformTag,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int minCount = 40,
|
int minCount = 50,
|
||||||
int maxPages = 8,
|
int maxPages = 12,
|
||||||
}) async {
|
}) async {
|
||||||
final wanted = languages
|
final wantedLangs = languages
|
||||||
.map((l) => l.trim().toLowerCase())
|
.map((l) => l.trim().toLowerCase())
|
||||||
.where((l) => l.isNotEmpty)
|
.where((l) => l.isNotEmpty)
|
||||||
.toSet();
|
.toSet();
|
||||||
if (wanted.isEmpty) {
|
final tagNeedle = freeformTag?.trim().toLowerCase();
|
||||||
return gameId == null
|
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
|
||||||
? getTopStreams(cursor: cursor)
|
|
||||||
: getStreamsUnderCategory(gameId: gameId, cursor: cursor);
|
bool matches(StreamTwitch stream) {
|
||||||
|
if (wantedLangs.isNotEmpty &&
|
||||||
|
!wantedLangs.contains(stream.language.toLowerCase())) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (wanted.length == 1) {
|
if (hasTag) {
|
||||||
final lang = wanted.first;
|
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
|
||||||
|
if (!hit) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: no tag filter and 0–1 languages → one Helix call.
|
||||||
|
if (!hasTag && wantedLangs.length <= 1) {
|
||||||
|
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
|
||||||
return gameId == null
|
return gameId == null
|
||||||
? getTopStreams(cursor: cursor, language: lang)
|
? getTopStreams(cursor: cursor, language: lang)
|
||||||
: getStreamsUnderCategory(
|
: getStreamsUnderCategory(
|
||||||
|
|
@ -280,6 +293,11 @@ class TwitchApi extends BaseApiClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When filtering by tag and exactly one language, still pass language to
|
||||||
|
// Helix so each page is denser in matches.
|
||||||
|
final helixLanguage =
|
||||||
|
wantedLangs.length == 1 ? wantedLangs.first : null;
|
||||||
|
|
||||||
final matched = <StreamTwitch>[];
|
final matched = <StreamTwitch>[];
|
||||||
String? pageCursor = cursor;
|
String? pageCursor = cursor;
|
||||||
String? nextCursor;
|
String? nextCursor;
|
||||||
|
|
@ -287,14 +305,16 @@ class TwitchApi extends BaseApiClient {
|
||||||
|
|
||||||
while (matched.length < minCount && pages < maxPages) {
|
while (matched.length < minCount && pages < maxPages) {
|
||||||
final page = gameId == null
|
final page = gameId == null
|
||||||
? await getTopStreams(cursor: pageCursor)
|
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||||||
: await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor);
|
: await getStreamsUnderCategory(
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: pageCursor,
|
||||||
|
language: helixLanguage,
|
||||||
|
);
|
||||||
pages++;
|
pages++;
|
||||||
nextCursor = page.pagination['cursor'];
|
nextCursor = page.pagination['cursor'];
|
||||||
for (final stream in page.data) {
|
for (final stream in page.data) {
|
||||||
if (wanted.contains(stream.language.toLowerCase())) {
|
if (matches(stream)) matched.add(stream);
|
||||||
matched.add(stream);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (nextCursor == null || page.data.isEmpty) {
|
if (nextCursor == null || page.data.isEmpty) {
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
|
|
@ -309,6 +329,22 @@ class TwitchApi extends BaseApiClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deprecated name — use [getDirectoryStreams].
|
||||||
|
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
||||||
|
required List<String> languages,
|
||||||
|
String? gameId,
|
||||||
|
String? cursor,
|
||||||
|
int minCount = 40,
|
||||||
|
int maxPages = 8,
|
||||||
|
}) =>
|
||||||
|
getDirectoryStreams(
|
||||||
|
languages: languages,
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: cursor,
|
||||||
|
minCount: minCount,
|
||||||
|
maxPages: maxPages,
|
||||||
|
);
|
||||||
|
|
||||||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||||||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
// Helix-style placeholder so existing thumbnail widgets keep working.
|
// Helix-style placeholder so existing thumbnail widgets keep working.
|
||||||
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
|
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
|
||||||
language,
|
language,
|
||||||
|
const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ class StreamTwitch {
|
||||||
@JsonKey(defaultValue: '')
|
@JsonKey(defaultValue: '')
|
||||||
final String language;
|
final String language;
|
||||||
|
|
||||||
|
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
|
||||||
|
@JsonKey(defaultValue: <String>[])
|
||||||
|
final List<String> tags;
|
||||||
|
|
||||||
const StreamTwitch(
|
const StreamTwitch(
|
||||||
this.userId,
|
this.userId,
|
||||||
this.userLogin,
|
this.userLogin,
|
||||||
|
|
@ -30,6 +34,7 @@ class StreamTwitch {
|
||||||
this.startedAt,
|
this.startedAt,
|
||||||
this.thumbnailUrl, [
|
this.thumbnailUrl, [
|
||||||
this.language = '',
|
this.language = '',
|
||||||
|
this.tags = const [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
||||||
json['started_at'] as String,
|
json['started_at'] as String,
|
||||||
json['thumbnail_url'] as String,
|
json['thumbnail_url'] as String,
|
||||||
json['language'] as String? ?? '',
|
json['language'] as String? ?? '',
|
||||||
|
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,24 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
import 'package:flutter_mobx/flutter_mobx.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/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
import 'package:frosty/widgets/stream_sort_button.dart';
|
import 'package:frosty/widgets/stream_sort_button.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
/// Streams matching a freeform tag (search → tap tag).
|
/// Streams matching a freeform tag (search → tap tag).
|
||||||
class TagStreams extends StatefulWidget {
|
class TagStreams extends StatelessWidget {
|
||||||
static const routeName = 'TagStreams';
|
static const routeName = 'TagStreams';
|
||||||
|
|
||||||
final String tagName;
|
final String tagName;
|
||||||
|
|
||||||
const TagStreams({super.key, required this.tagName});
|
const TagStreams({super.key, required this.tagName});
|
||||||
|
|
||||||
@override
|
|
||||||
State<TagStreams> createState() => _TagStreamsState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TagStreamsState extends State<TagStreams> {
|
|
||||||
var _sortOrder = StreamViewerSort.viewersDesc;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final settingsStore = context.watch<SettingsStore>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
|
@ -31,14 +27,15 @@ class _TagStreamsState extends State<TagStreams> {
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: StreamsList(
|
child: Observer(
|
||||||
key: ValueKey(_sortOrder),
|
builder: (_) => StreamsList(
|
||||||
listType: ListType.tag,
|
listType: ListType.tag,
|
||||||
freeformTag: widget.tagName,
|
freeformTag: tagName,
|
||||||
sortOrder: _sortOrder,
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
showJumpButton: true,
|
showJumpButton: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -61,15 +58,18 @@ class _TagStreamsState extends State<TagStreams> {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.tagName,
|
tagName,
|
||||||
style: theme.textTheme.titleLarge,
|
style: theme.textTheme.titleLarge,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
StreamSortButton(
|
Observer(
|
||||||
sortOrder: _sortOrder,
|
builder: (_) => StreamSortButton(
|
||||||
onChanged: (order) => setState(() => _sortOrder = order),
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
onChanged: (order) =>
|
||||||
|
settingsStore.streamViewerSort = order,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,18 @@ abstract class ListStoreBase with Store {
|
||||||
_getCategoryDetails();
|
_getCategoryDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload directory lists when the language filter changes.
|
// Reload directory lists when the language filter or sort changes.
|
||||||
if (listType != ListType.followed) {
|
if (listType != ListType.followed) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
_languageFilterReactionDisposer = reaction(
|
_languageFilterReactionDisposer = reaction(
|
||||||
(_) => settingsStore.streamLanguages.toList(),
|
(_) => [
|
||||||
(_) => refreshStreams(),
|
...settingsStore.streamLanguages,
|
||||||
|
settingsStore.streamViewerSort,
|
||||||
|
],
|
||||||
|
(_) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
|
refreshStreams();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,6 +259,14 @@ abstract class ListStoreBase with Store {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final languages = _languageFilter;
|
final languages = _languageFilter;
|
||||||
|
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||||
|
// Asc needs a deeper Helix scan so client-side re-sort can surface
|
||||||
|
// lower-viewer streams (Helix itself is always viewer-desc).
|
||||||
|
final minCount = isAsc ? 80 : 50;
|
||||||
|
final maxPages = isAsc
|
||||||
|
? (_streamsCursor == null ? 20 : 10)
|
||||||
|
: (_streamsCursor == null ? 12 : 8);
|
||||||
|
|
||||||
final StreamsTwitch newStreams;
|
final StreamsTwitch newStreams;
|
||||||
switch (listType) {
|
switch (listType) {
|
||||||
case ListType.followed:
|
case ListType.followed:
|
||||||
|
|
@ -261,50 +276,36 @@ abstract class ListStoreBase with Store {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ListType.top:
|
case ListType.top:
|
||||||
// Ascending sort needs GQL (Helix is always desc).
|
newStreams = await twitchApi.getDirectoryStreams(
|
||||||
if (sortOrder == StreamViewerSort.viewersAsc &&
|
|
||||||
twitchGqlApi != null) {
|
|
||||||
newStreams = await twitchGqlApi!.getStreams(
|
|
||||||
sort: sortOrder,
|
|
||||||
languages: languages.isEmpty ? null : languages,
|
|
||||||
cursor: _streamsCursor,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
newStreams = await twitchApi.getStreamsMatchingLanguages(
|
|
||||||
languages: languages,
|
languages: languages,
|
||||||
cursor: _streamsCursor,
|
cursor: _streamsCursor,
|
||||||
|
minCount: minCount,
|
||||||
|
maxPages: maxPages,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case ListType.category:
|
case ListType.category:
|
||||||
if (sortOrder == StreamViewerSort.viewersAsc &&
|
newStreams = await twitchApi.getDirectoryStreams(
|
||||||
twitchGqlApi != null) {
|
|
||||||
newStreams = await twitchGqlApi!.getGameStreams(
|
|
||||||
gameId: categoryId!,
|
|
||||||
sort: sortOrder,
|
|
||||||
languages: languages.isEmpty ? null : languages,
|
|
||||||
cursor: _streamsCursor,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
newStreams = await twitchApi.getStreamsMatchingLanguages(
|
|
||||||
languages: languages,
|
languages: languages,
|
||||||
gameId: categoryId!,
|
gameId: categoryId!,
|
||||||
cursor: _streamsCursor,
|
cursor: _streamsCursor,
|
||||||
|
minCount: minCount,
|
||||||
|
maxPages: maxPages,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case ListType.tag:
|
case ListType.tag:
|
||||||
newStreams = await twitchGqlApi!.getStreams(
|
// Helix + client tag filter (GQL freeformTags can't paginate without
|
||||||
freeformTags: [freeformTag!],
|
// a non-bot Client-Integrity token).
|
||||||
sort: sortOrder,
|
newStreams = await twitchApi.getDirectoryStreams(
|
||||||
languages: languages.isEmpty ? null : languages,
|
languages: languages,
|
||||||
|
freeformTag: freeformTag,
|
||||||
cursor: _streamsCursor,
|
cursor: _streamsCursor,
|
||||||
|
minCount: minCount,
|
||||||
|
maxPages: maxPages,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GQL page-2 often returns empty `data` under IntegrityCheckFailed
|
// Empty continuation ⇒ end of directory.
|
||||||
// without throwing — treat empty continuation pages as end-of-list.
|
|
||||||
final isContinuation = _streamsCursor != null;
|
final isContinuation = _streamsCursor != null;
|
||||||
if (isContinuation && newStreams.data.isEmpty) {
|
if (isContinuation && newStreams.data.isEmpty) {
|
||||||
_streamsCursor = null;
|
_streamsCursor = null;
|
||||||
|
|
@ -315,7 +316,6 @@ abstract class ListStoreBase with Store {
|
||||||
if (_streamsCursor == null) {
|
if (_streamsCursor == null) {
|
||||||
_allStreams = newStreams.data.asObservable();
|
_allStreams = newStreams.data.asObservable();
|
||||||
} else {
|
} else {
|
||||||
// Dedupe in case of overlapping over-fetch pages.
|
|
||||||
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||||
_allStreams.addAll(
|
_allStreams.addAll(
|
||||||
newStreams.data.where((s) => !existingIds.contains(s.userId)),
|
newStreams.data.where((s) => !existingIds.contains(s.userId)),
|
||||||
|
|
@ -324,11 +324,11 @@ abstract class ListStoreBase with Store {
|
||||||
final next = newStreams.pagination['cursor'];
|
final next = newStreams.pagination['cursor'];
|
||||||
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
|
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
|
||||||
|
|
||||||
|
_applyViewerSort();
|
||||||
_error = null;
|
_error = null;
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
_error = 'Unable to connect to Twitch';
|
_error = 'Unable to connect to Twitch';
|
||||||
debugPrint('Streams SocketException: No internet connection');
|
debugPrint('Streams SocketException: No internet connection');
|
||||||
// Stop retry storms on failed continuations.
|
|
||||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
_streamsCursor = null;
|
_streamsCursor = null;
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,6 @@ abstract class ListStoreBase with Store {
|
||||||
debugPrint('Streams ApiException: $e');
|
debugPrint('Streams ApiException: $e');
|
||||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
_streamsCursor = null;
|
_streamsCursor = null;
|
||||||
// Don't surface integrity / page-2 failures as a full-list error.
|
|
||||||
if (_allStreams.isNotEmpty) _error = null;
|
if (_allStreams.isNotEmpty) _error = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -352,12 +351,19 @@ abstract class ListStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changes viewer sort and reloads from the first page.
|
void _applyViewerSort() {
|
||||||
|
final sorted = _allStreams.toList();
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final cmp = a.viewerCount.compareTo(b.viewerCount);
|
||||||
|
return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
_allStreams = sorted.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists viewer sort and reloads via the settings reaction.
|
||||||
@action
|
@action
|
||||||
Future<void> setSortOrder(StreamViewerSort order) async {
|
Future<void> setSortOrder(StreamViewerSort order) async {
|
||||||
if (sortOrder == order) return;
|
settingsStore.streamViewerSort = order;
|
||||||
sortOrder = order;
|
|
||||||
await refreshStreams();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class CategoryStreams extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryStreamsState extends State<CategoryStreams> {
|
class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
var _sortOrder = StreamViewerSort.viewersDesc;
|
late final _settingsStore = context.read<SettingsStore>();
|
||||||
|
|
||||||
late final _listStore = ListStore(
|
late final _listStore = ListStore(
|
||||||
listType: ListType.category,
|
listType: ListType.category,
|
||||||
|
|
@ -35,7 +35,8 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
authStore: context.read<AuthStore>(),
|
authStore: context.read<AuthStore>(),
|
||||||
twitchApi: context.read<TwitchApi>(),
|
twitchApi: context.read<TwitchApi>(),
|
||||||
twitchGqlApi: context.read<TwitchGqlApi>(),
|
twitchGqlApi: context.read<TwitchGqlApi>(),
|
||||||
settingsStore: context.read<SettingsStore>(),
|
settingsStore: _settingsStore,
|
||||||
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -50,14 +51,15 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
children: [
|
children: [
|
||||||
// Stream list content behind the pinned elements
|
// Stream list content behind the pinned elements
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: StreamsList(
|
child: Observer(
|
||||||
key: ValueKey(_sortOrder),
|
builder: (_) => StreamsList(
|
||||||
listType: ListType.category,
|
listType: ListType.category,
|
||||||
categoryId: widget.categoryId,
|
categoryId: widget.categoryId,
|
||||||
sortOrder: _sortOrder,
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
showJumpButton: true,
|
showJumpButton: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Single blurred background spanning app bar and category card
|
// Single blurred background spanning app bar and category card
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -84,10 +86,12 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
StreamSortButton(
|
Observer(
|
||||||
sortOrder: _sortOrder,
|
builder: (_) => StreamSortButton(
|
||||||
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
onChanged: (order) =>
|
onChanged: (order) =>
|
||||||
setState(() => _sortOrder = order),
|
_settingsStore.streamViewerSort = order,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
import 'package:flutter_mobx/flutter_mobx.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/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
import 'package:frosty/widgets/stream_sort_button.dart';
|
import 'package:frosty/widgets/stream_sort_button.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class TopSection extends StatefulWidget {
|
class TopSection extends StatefulWidget {
|
||||||
final HomeStore homeStore;
|
final HomeStore homeStore;
|
||||||
|
|
@ -19,7 +21,6 @@ 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() {
|
||||||
|
|
@ -32,6 +33,7 @@ class _TopSectionState extends State<TopSection>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final settingsStore = context.watch<SettingsStore>();
|
||||||
final showSort = _tabBarController.index == 0;
|
final showSort = _tabBarController.index == 0;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
|
|
@ -40,13 +42,14 @@ class _TopSectionState extends State<TopSection>
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabBarController,
|
controller: _tabBarController,
|
||||||
children: [
|
children: [
|
||||||
StreamsList(
|
Observer(
|
||||||
key: ValueKey(_sortOrder),
|
builder: (_) => StreamsList(
|
||||||
listType: ListType.top,
|
listType: ListType.top,
|
||||||
sortOrder: _sortOrder,
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
scrollController:
|
scrollController:
|
||||||
widget.homeStore.topSectionScrollControllers[0],
|
widget.homeStore.topSectionScrollControllers[0],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Categories(
|
Categories(
|
||||||
scrollController:
|
scrollController:
|
||||||
widget.homeStore.topSectionScrollControllers[1],
|
widget.homeStore.topSectionScrollControllers[1],
|
||||||
|
|
@ -78,9 +81,12 @@ class _TopSectionState extends State<TopSection>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showSort)
|
if (showSort)
|
||||||
StreamSortButton(
|
Observer(
|
||||||
sortOrder: _sortOrder,
|
builder: (_) => StreamSortButton(
|
||||||
onChanged: (order) => setState(() => _sortOrder = order),
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
onChanged: (order) =>
|
||||||
|
settingsStore.streamViewerSort = order,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
|
@ -60,6 +61,16 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var streamLanguages = defaultStreamLanguages;
|
var streamLanguages = defaultStreamLanguages;
|
||||||
|
|
||||||
|
/// Persistent viewer sort for Top / category / tag directories.
|
||||||
|
static const defaultStreamViewerSort = StreamViewerSort.viewersDesc;
|
||||||
|
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: defaultStreamViewerSort,
|
||||||
|
unknownEnumValue: StreamViewerSort.viewersDesc,
|
||||||
|
)
|
||||||
|
@observable
|
||||||
|
var streamViewerSort = defaultStreamViewerSort;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void resetGeneralSettings() {
|
void resetGeneralSettings() {
|
||||||
themeType = defaultThemeType;
|
themeType = defaultThemeType;
|
||||||
|
|
@ -70,6 +81,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
|
|
||||||
launchUrlExternal = defaultLaunchUrlExternal;
|
launchUrlExternal = defaultLaunchUrlExternal;
|
||||||
streamLanguages = defaultStreamLanguages;
|
streamLanguages = defaultStreamLanguages;
|
||||||
|
streamViewerSort = defaultStreamViewerSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Video Settings
|
// * Video Settings
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
['de']
|
['de']
|
||||||
|
..streamViewerSort =
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$StreamViewerSortEnumMap,
|
||||||
|
json['streamViewerSort'],
|
||||||
|
unknownValue: StreamViewerSort.viewersDesc,
|
||||||
|
) ??
|
||||||
|
StreamViewerSort.viewersDesc
|
||||||
..showVideo = json['showVideo'] as bool? ?? true
|
..showVideo = json['showVideo'] as bool? ?? true
|
||||||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||||
|
|
@ -110,6 +117,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'largeStreamCard': instance.largeStreamCard,
|
'largeStreamCard': instance.largeStreamCard,
|
||||||
'launchUrlExternal': instance.launchUrlExternal,
|
'launchUrlExternal': instance.launchUrlExternal,
|
||||||
'streamLanguages': instance.streamLanguages,
|
'streamLanguages': instance.streamLanguages,
|
||||||
|
'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!,
|
||||||
'showVideo': instance.showVideo,
|
'showVideo': instance.showVideo,
|
||||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||||
'useTextureRendering': instance.useTextureRendering,
|
'useTextureRendering': instance.useTextureRendering,
|
||||||
|
|
@ -165,6 +173,11 @@ const _$ThemeTypeEnumMap = {
|
||||||
ThemeType.dark: 'dark',
|
ThemeType.dark: 'dark',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$StreamViewerSortEnumMap = {
|
||||||
|
StreamViewerSort.viewersDesc: 'viewersDesc',
|
||||||
|
StreamViewerSort.viewersAsc: 'viewersAsc',
|
||||||
|
};
|
||||||
|
|
||||||
const _$TimestampTypeEnumMap = {
|
const _$TimestampTypeEnumMap = {
|
||||||
TimestampType.disabled: 'disabled',
|
TimestampType.disabled: 'disabled',
|
||||||
TimestampType.twelve: 'twelve',
|
TimestampType.twelve: 'twelve',
|
||||||
|
|
@ -302,6 +315,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$streamViewerSortAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.streamViewerSort',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamViewerSort get streamViewerSort {
|
||||||
|
_$streamViewerSortAtom.reportRead();
|
||||||
|
return super.streamViewerSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set streamViewerSort(StreamViewerSort value) {
|
||||||
|
_$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () {
|
||||||
|
super.streamViewerSort = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$showVideoAtom = Atom(
|
late final _$showVideoAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showVideo',
|
name: '_SettingsStoreBase.showVideo',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -1286,6 +1317,7 @@ showThumbnails: ${showThumbnails},
|
||||||
largeStreamCard: ${largeStreamCard},
|
largeStreamCard: ${largeStreamCard},
|
||||||
launchUrlExternal: ${launchUrlExternal},
|
launchUrlExternal: ${launchUrlExternal},
|
||||||
streamLanguages: ${streamLanguages},
|
streamLanguages: ${streamLanguages},
|
||||||
|
streamViewerSort: ${streamViewerSort},
|
||||||
showVideo: ${showVideo},
|
showVideo: ${showVideo},
|
||||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||||
useTextureRendering: ${useTextureRendering},
|
useTextureRendering: ${useTextureRendering},
|
||||||
|
|
|
||||||
|
|
@ -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.4+74
|
version: 5.2.5+75
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue