Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 378c230815 | |||
| eb39a4ac00 | |||
| 59290b23b1 | |||
| 9a08c40136 | |||
| 9ced2fe212 | |||
| 3fb393166f | |||
| 383e786479 | |||
| 30848e6fe8 | |||
| 318dc84dc5 | |||
| 5a787e0134 | |||
| 47150d0ab3 | |||
| 9d392d8d6a | |||
| cff29232c7 |
41 changed files with 3292 additions and 275 deletions
11
DESIGN.md
11
DESIGN.md
|
|
@ -45,6 +45,17 @@ Helix cannot filter by freeform tags or sort ascending. GQL covers both:
|
||||||
|
|
||||||
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
|
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
|
||||||
|
|
||||||
|
## Stream languages (v5.2.4+)
|
||||||
|
|
||||||
|
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
|
||||||
|
|
||||||
|
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
|
||||||
|
- Viewer sort is persisted (`streamViewerSort`)
|
||||||
|
- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session.
|
||||||
|
- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`.
|
||||||
|
- **Top / category most viewers (desc):** Helix directory order (paginates)
|
||||||
|
- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback
|
||||||
|
|
||||||
## 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`.
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,28 @@
|
||||||
## v5.2.1
|
## v5.3.1
|
||||||
|
|
||||||
September 30, 2026
|
October 12, 2026
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- Added TwitchAdSolutions-style ad blocking for the native player (and best-effort WebView inject), with a Settings toggle.
|
|
||||||
- Added Forgejo Obtainium release builds for this fork.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.2.0
|
|
||||||
|
|
||||||
August 16, 2026
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- Added moderator actions to delete messages, timeout, ban, and unban users from the message long-press menu and user panel (by @francislavoie on GitHub).
|
|
||||||
- Added channel bubbles in Twitch shared chat, so you can focus one participant or hide their messages.
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Improved the default video player by switching to native. It handles Picture-in-Picture, quality selection, and latency better than the legacy WebView player.
|
|
||||||
- Improved native player stability across device rotation (by @francislavoie on GitHub).
|
|
||||||
- [Android] Improved native player latency for live streams (by @francislavoie on GitHub).
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream.
|
- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh.
|
||||||
- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v5.1.0
|
## v5.3.0
|
||||||
|
|
||||||
June 13, 2026
|
October 12, 2026
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery.
|
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||||
- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together.
|
|
||||||
- Added new chat settings for keeping the screen on and showing timestamps on historical messages.
|
|
||||||
- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels.
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Improved chat send reliability, especially when chat delay is enabled.
|
|
||||||
- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages.
|
|
||||||
- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks.
|
|
||||||
- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed recent messages failing on valueless or escaped IRC tags.
|
|
||||||
- Fixed shared chat badges and emotes lingering after leaving shared chat.
|
|
||||||
- Fixed recent searches not saving from type-to-search.
|
|
||||||
- Fixed auto-synced chat delay not applying when toggled on mid-session.
|
|
||||||
- Fixed long chat inputs overlapping the message list.
|
|
||||||
- Fixed a video refresh race that could leave a black screen with only the play button visible.
|
|
||||||
- Fixed fullscreen button and system UI state getting out of sync.
|
|
||||||
- Fixed landscape safe area and iPad gesture padding issues.
|
|
||||||
- Fixed Twitch/API auth requests that could hang instead of failing cleanly.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v5.0.5
|
## v5.2.9
|
||||||
|
|
||||||
January 9, 2026
|
October 7, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed lag/frame drops in overlay chat mode.
|
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
||||||
- Fixed draggable divider still interactable in fullscreen mode.
|
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||||
- Fixed landscape bottom padding in chat bottom bar.
|
|
||||||
- [iOS] Fixed user agent breaking login.
|
|
||||||
|
|
||||||
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ class UnauthorizedException extends ApiException {
|
||||||
: super(message, 401, DioExceptionType.badResponse);
|
: super(message, 401, DioExceptionType.badResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
|
||||||
|
class IntegrityCheckException extends ApiException {
|
||||||
|
const IntegrityCheckException([
|
||||||
|
String message = 'Twitch integrity check failed',
|
||||||
|
]) : super(message);
|
||||||
|
}
|
||||||
|
|
||||||
/// Base API client to eliminate code duplication across API services
|
/// Base API client to eliminate code duplication across API services
|
||||||
abstract class BaseApiClient {
|
abstract class BaseApiClient {
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
|
||||||
|
|
@ -175,11 +175,23 @@ class TwitchApi extends BaseApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
|
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
|
||||||
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
|
///
|
||||||
|
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
|
||||||
|
Future<StreamsTwitch> getTopStreams({
|
||||||
|
String? cursor,
|
||||||
|
String? language,
|
||||||
|
int first = 100,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'first': first,
|
||||||
|
if (cursor != null) 'after': cursor,
|
||||||
|
if (language != null && language.isNotEmpty) 'language': language,
|
||||||
|
};
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
'/streams',
|
'/streams',
|
||||||
queryParameters: cursor != null ? {'after': cursor} : null,
|
queryParameters: queryParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
return StreamsTwitch.fromJson(data);
|
return StreamsTwitch.fromJson(data);
|
||||||
|
|
@ -190,7 +202,7 @@ class TwitchApi extends BaseApiClient {
|
||||||
required String id,
|
required String id,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'user_id': id};
|
final queryParams = {'user_id': id, 'first': '100'};
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
@ -206,7 +218,7 @@ class TwitchApi extends BaseApiClient {
|
||||||
required String userId,
|
required String userId,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'user_id': userId, 'first': '20'};
|
final queryParams = {'user_id': userId, 'first': '100'};
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
@ -217,19 +229,153 @@ class TwitchApi extends BaseApiClient {
|
||||||
return FollowedChannels.fromJson(data);
|
return FollowedChannels.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete
|
||||||
|
/// follows were removed from the public API).
|
||||||
|
Future<bool> isFollowingChannel({
|
||||||
|
required String userId,
|
||||||
|
required String broadcasterId,
|
||||||
|
}) async {
|
||||||
|
final data = await get<JsonMap>(
|
||||||
|
'/channels/followed',
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': userId,
|
||||||
|
'broadcaster_id': broadcasterId,
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final list = data['data'] as List? ?? const [];
|
||||||
|
return list.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
||||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||||
required String gameId,
|
required String gameId,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
|
String? language,
|
||||||
|
int first = 100,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'game_id': gameId};
|
final queryParams = <String, dynamic>{
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
'game_id': gameId,
|
||||||
|
'first': first,
|
||||||
|
if (cursor != null) 'after': cursor,
|
||||||
|
if (language != null && language.isNotEmpty) 'language': language,
|
||||||
|
};
|
||||||
|
|
||||||
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
||||||
|
|
||||||
return StreamsTwitch.fromJson(data);
|
return StreamsTwitch.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches Helix directory pages until enough streams match [languages] and
|
||||||
|
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||||||
|
///
|
||||||
|
/// Helix is always viewer-desc and supports at most one `language` query
|
||||||
|
/// param; tags are filtered client-side. Used as fallback when GQL directory
|
||||||
|
/// pagination lacks a Client-Integrity token.
|
||||||
|
///
|
||||||
|
/// When a [freeformTag] filter yields no matches in a Helix window, this
|
||||||
|
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
|
||||||
|
/// callers can continue — an empty match set alone is not end-of-list.
|
||||||
|
Future<StreamsTwitch> getDirectoryStreams({
|
||||||
|
List<String> languages = const [],
|
||||||
|
String? gameId,
|
||||||
|
String? freeformTag,
|
||||||
|
String? cursor,
|
||||||
|
int minCount = 50,
|
||||||
|
int maxPages = 12,
|
||||||
|
}) async {
|
||||||
|
final wantedLangs = languages
|
||||||
|
.map((l) => l.trim().toLowerCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
final tagNeedle = freeformTag?.trim().toLowerCase();
|
||||||
|
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
|
||||||
|
|
||||||
|
bool matches(StreamTwitch stream) {
|
||||||
|
if (wantedLangs.isNotEmpty &&
|
||||||
|
!wantedLangs.contains(stream.language.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasTag) {
|
||||||
|
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
|
||||||
|
? getTopStreams(cursor: cursor, language: lang)
|
||||||
|
: getStreamsUnderCategory(
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: cursor,
|
||||||
|
language: lang,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparse tags need a deeper Helix scan per request.
|
||||||
|
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
|
||||||
|
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
|
||||||
|
|
||||||
|
// 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>[];
|
||||||
|
String? pageCursor = cursor;
|
||||||
|
String? nextCursor;
|
||||||
|
var pages = 0;
|
||||||
|
|
||||||
|
while (matched.length < effectiveMin && pages < effectiveMax) {
|
||||||
|
final page = gameId == null
|
||||||
|
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||||||
|
: await getStreamsUnderCategory(
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: pageCursor,
|
||||||
|
language: helixLanguage,
|
||||||
|
);
|
||||||
|
pages++;
|
||||||
|
nextCursor = page.pagination['cursor'];
|
||||||
|
for (final stream in page.data) {
|
||||||
|
if (matches(stream)) matched.add(stream);
|
||||||
|
}
|
||||||
|
if (nextCursor == null ||
|
||||||
|
nextCursor.isEmpty ||
|
||||||
|
page.data.isEmpty) {
|
||||||
|
nextCursor = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageCursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamsTwitch(
|
||||||
|
matched,
|
||||||
|
nextCursor != null && nextCursor.isNotEmpty
|
||||||
|
? {'cursor': nextCursor}
|
||||||
|
: const {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.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';
|
import 'package:frosty/models/stream.dart';
|
||||||
|
|
@ -31,21 +32,49 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<JsonMap> _gql(Map<String, Object?> body) async {
|
Future<JsonMap> _gql(
|
||||||
|
Map<String, Object?> body, {
|
||||||
|
Map<String, String>? extraHeaders,
|
||||||
|
}) async {
|
||||||
final response = await post<JsonMap>(
|
final response = await post<JsonMap>(
|
||||||
'/gql',
|
'/gql',
|
||||||
data: body,
|
data: body,
|
||||||
headers: _gqlHeaders,
|
headers: {..._gqlHeaders, ...?extraHeaders},
|
||||||
);
|
);
|
||||||
final errors = response['errors'];
|
final errors = response['errors'];
|
||||||
if (errors is List && errors.isNotEmpty && response['data'] == null) {
|
if (errors is List && errors.isNotEmpty) {
|
||||||
final message = (errors.first as JsonMap)['message'] as String? ??
|
final message = (errors.first as JsonMap)['message'] as String? ??
|
||||||
'Twitch GQL error';
|
'Twitch GQL error';
|
||||||
throw ApiException(message);
|
if (message.toLowerCase().contains('integrity')) {
|
||||||
|
throw IntegrityCheckException(message);
|
||||||
|
}
|
||||||
|
final data = response['data'];
|
||||||
|
final dataEmpty = data == null ||
|
||||||
|
(data is Map &&
|
||||||
|
data.values.every((value) => value == null));
|
||||||
|
if (dataEmpty) {
|
||||||
|
throw ApiException(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional web-session headers for directory pagination (page-2+).
|
||||||
|
Map<String, String> _directoryHeaders({
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (authToken != null && authToken.isNotEmpty)
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
|
||||||
|
if (integrityToken != null && integrityToken.isNotEmpty)
|
||||||
|
'Client-Integrity': integrityToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
@ -205,60 +234,104 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live streams matching optional [freeformTags], sorted by viewer count.
|
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
|
||||||
|
///
|
||||||
|
/// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
|
||||||
|
/// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
|
||||||
|
/// them page-2 throws [IntegrityCheckException].
|
||||||
Future<StreamsTwitch> getStreams({
|
Future<StreamsTwitch> getStreams({
|
||||||
List<String>? freeformTags,
|
List<String>? freeformTags,
|
||||||
|
List<String>? languages,
|
||||||
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int first = 30,
|
int first = 30,
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _gql({
|
final cappedFirst = first.clamp(1, 30);
|
||||||
'operationName': 'FrostyTopStreams',
|
final gqlLangs = languages == null || languages.isEmpty
|
||||||
'query':
|
? null
|
||||||
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort) {'
|
: languages
|
||||||
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags }) {'
|
.map((l) => l.trim().toUpperCase())
|
||||||
r' edges { cursor node {'
|
.where((l) => l.isNotEmpty)
|
||||||
r' id title viewersCount createdAt'
|
.toList();
|
||||||
r' game { id name }'
|
|
||||||
r' broadcaster { id login displayName }'
|
final response = await _gql(
|
||||||
r' } } pageInfo { hasNextPage } } }',
|
{
|
||||||
'variables': {
|
'operationName': 'FrostyTopStreams',
|
||||||
'first': first,
|
'query':
|
||||||
'after': cursor,
|
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
|
||||||
'tags': freeformTags,
|
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
|
||||||
'sort': sort.gqlValue,
|
r' edges { cursor node {'
|
||||||
|
r' id title viewersCount createdAt language'
|
||||||
|
r' game { id name }'
|
||||||
|
r' broadcaster { id login displayName }'
|
||||||
|
r' } } pageInfo { hasNextPage } } }',
|
||||||
|
'variables': {
|
||||||
|
'first': cappedFirst,
|
||||||
|
'after': cursor,
|
||||||
|
'tags': freeformTags,
|
||||||
|
'sort': sort.gqlValue,
|
||||||
|
'langs': gqlLangs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
extraHeaders: _directoryHeaders(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final connection =
|
final connection =
|
||||||
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
|
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
|
||||||
return _streamsFromConnection(connection);
|
return _streamsFromConnection(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live streams under a category/game, with viewer sort (Helix has no asc).
|
/// Live streams under a category/game, with viewer sort and optional languages.
|
||||||
Future<StreamsTwitch> getGameStreams({
|
Future<StreamsTwitch> getGameStreams({
|
||||||
required String gameId,
|
required String gameId,
|
||||||
|
List<String>? languages,
|
||||||
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int first = 30,
|
int first = 30,
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _gql({
|
final cappedFirst = first.clamp(1, 30);
|
||||||
'operationName': 'FrostyGameStreams',
|
final gqlLangs = languages == null || languages.isEmpty
|
||||||
'query':
|
? null
|
||||||
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort) {'
|
: languages
|
||||||
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort }) {'
|
.map((l) => l.trim().toUpperCase())
|
||||||
r' edges { cursor node {'
|
.where((l) => l.isNotEmpty)
|
||||||
r' id title viewersCount createdAt'
|
.toList();
|
||||||
r' game { id name }'
|
|
||||||
r' broadcaster { id login displayName }'
|
final response = await _gql(
|
||||||
r' } } pageInfo { hasNextPage } } } }',
|
{
|
||||||
'variables': {
|
'operationName': 'FrostyGameStreams',
|
||||||
'id': gameId,
|
'query':
|
||||||
'first': first,
|
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
|
||||||
'after': cursor,
|
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
|
||||||
'sort': sort.gqlValue,
|
r' edges { cursor node {'
|
||||||
|
r' id title viewersCount createdAt language'
|
||||||
|
r' game { id name }'
|
||||||
|
r' broadcaster { id login displayName }'
|
||||||
|
r' } } pageInfo { hasNextPage } } } }',
|
||||||
|
'variables': {
|
||||||
|
'id': gameId,
|
||||||
|
'first': cappedFirst,
|
||||||
|
'after': cursor,
|
||||||
|
'sort': sort.gqlValue,
|
||||||
|
'langs': gqlLangs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
extraHeaders: _directoryHeaders(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
|
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
|
||||||
if (game == null) {
|
if (game == null) {
|
||||||
|
|
@ -267,10 +340,181 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
|
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted hashes used by the Twitch web Follow button (same as Xtra).
|
||||||
|
static const _followUserHash =
|
||||||
|
'800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08';
|
||||||
|
static const _unfollowUserHash =
|
||||||
|
'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880';
|
||||||
|
|
||||||
|
/// Follows [targetId] using the web GQL session. Helix no longer supports this.
|
||||||
|
///
|
||||||
|
/// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts
|
||||||
|
/// need a [integrityToken] + matching [deviceId]; callers should retry after
|
||||||
|
/// [fetchIntegrityToken] when [IntegrityCheckException] is thrown.
|
||||||
|
Future<void> followUser({
|
||||||
|
required String targetId,
|
||||||
|
required String authToken,
|
||||||
|
bool disableNotifications = false,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
await _mutateFollow(
|
||||||
|
operationName: 'FollowButton_FollowUser',
|
||||||
|
sha256Hash: _followUserHash,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
variables: {
|
||||||
|
'input': {
|
||||||
|
'disableNotifications': disableNotifications,
|
||||||
|
'targetID': targetId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
successPath: const ['followUser'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfollows [targetId] using the web GQL session.
|
||||||
|
Future<void> unfollowUser({
|
||||||
|
required String targetId,
|
||||||
|
required String authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
await _mutateFollow(
|
||||||
|
operationName: 'FollowButton_UnfollowUser',
|
||||||
|
sha256Hash: _unfollowUserHash,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
variables: {
|
||||||
|
'input': {'targetID': targetId},
|
||||||
|
},
|
||||||
|
successPath: const ['unfollowUser'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mints a Client-Integrity token for authenticated GQL mutations.
|
||||||
|
///
|
||||||
|
/// Bound to [deviceId] and [authToken]; send the same values on follow calls.
|
||||||
|
/// Tokens minted outside a real browser are usually flagged `is_bad_bot` —
|
||||||
|
/// those throw [IntegrityCheckException] so callers can fall back to a
|
||||||
|
/// WebView capture.
|
||||||
|
Future<({String token, int expirationMs})> fetchIntegrityToken({
|
||||||
|
required String authToken,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
final response = await post<JsonMap>(
|
||||||
|
'/integrity',
|
||||||
|
data: <String, dynamic>{},
|
||||||
|
headers: {
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
'X-Device-Id': deviceId,
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = response['token'] as String?;
|
||||||
|
final expiration = response['expiration'];
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw const ApiException('Twitch integrity token missing from response');
|
||||||
|
}
|
||||||
|
if (TwitchIntegrity.isBadBot(token)) {
|
||||||
|
throw const IntegrityCheckException(
|
||||||
|
'Twitch integrity token flagged as bad bot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final expirationMs = expiration is int
|
||||||
|
? expiration
|
||||||
|
: int.tryParse('$expiration') ??
|
||||||
|
TwitchIntegrity.expirationMs(token) ??
|
||||||
|
0;
|
||||||
|
return (token: token, expirationMs: expirationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutateFollow({
|
||||||
|
required String operationName,
|
||||||
|
required String sha256Hash,
|
||||||
|
required String authToken,
|
||||||
|
required JsonMap variables,
|
||||||
|
required List<String> successPath,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
|
||||||
|
if (integrityToken != null && integrityToken.isNotEmpty)
|
||||||
|
'Client-Integrity': integrityToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await post<dynamic>(
|
||||||
|
'/gql',
|
||||||
|
data: {
|
||||||
|
'operationName': operationName,
|
||||||
|
'variables': variables,
|
||||||
|
'extensions': {
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
'sha256Hash': sha256Hash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
final map = _gqlPayloadMap(response);
|
||||||
|
_throwIfGqlFailed(map);
|
||||||
|
|
||||||
|
JsonMap? cursor = map['data'] as JsonMap?;
|
||||||
|
for (final key in successPath) {
|
||||||
|
final next = cursor?[key];
|
||||||
|
if (next is JsonMap) {
|
||||||
|
cursor = next;
|
||||||
|
} else if (next == null) {
|
||||||
|
throw ApiException('Twitch $operationName returned no data');
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonMap _gqlPayloadMap(dynamic response) {
|
||||||
|
if (response is List) {
|
||||||
|
if (response.isEmpty || response.first is! Map) {
|
||||||
|
throw const ApiException('Unexpected Twitch GQL response');
|
||||||
|
}
|
||||||
|
return Map<String, dynamic>.from(response.first as Map);
|
||||||
|
}
|
||||||
|
if (response is Map) {
|
||||||
|
return Map<String, dynamic>.from(response);
|
||||||
|
}
|
||||||
|
throw const ApiException('Unexpected Twitch GQL response');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _throwIfGqlFailed(JsonMap map) {
|
||||||
|
final errors = map['errors'];
|
||||||
|
if (errors is! List || errors.isEmpty) return;
|
||||||
|
|
||||||
|
final message =
|
||||||
|
(errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error';
|
||||||
|
final lower = message.toLowerCase();
|
||||||
|
if (lower.contains('integrity')) {
|
||||||
|
throw IntegrityCheckException(message);
|
||||||
|
}
|
||||||
|
throw ApiException(message);
|
||||||
|
}
|
||||||
|
|
||||||
StreamsTwitch _streamsFromConnection(JsonMap connection) {
|
StreamsTwitch _streamsFromConnection(JsonMap connection) {
|
||||||
final edges = (connection['edges'] as List?) ?? const [];
|
final edges = (connection['edges'] as List?) ?? const [];
|
||||||
final pageInfo = connection['pageInfo'] as JsonMap?;
|
final pageInfo = connection['pageInfo'] as JsonMap?;
|
||||||
final hasNext = pageInfo?['hasNextPage'] as bool? ?? false;
|
// Prefer explicit pageInfo; if missing, assume more when we got a full page.
|
||||||
|
final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30;
|
||||||
|
|
||||||
final streams = <StreamTwitch>[];
|
final streams = <StreamTwitch>[];
|
||||||
String? nextCursor;
|
String? nextCursor;
|
||||||
|
|
@ -285,7 +529,9 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
|
|
||||||
return StreamsTwitch(
|
return StreamsTwitch(
|
||||||
streams,
|
streams,
|
||||||
hasNext && nextCursor != null ? {'cursor': nextCursor} : const {},
|
hasNext && nextCursor != null && nextCursor.isNotEmpty
|
||||||
|
? {'cursor': nextCursor}
|
||||||
|
: const {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +539,7 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
|
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
|
||||||
final game = node['game'] as JsonMap? ?? const {};
|
final game = node['game'] as JsonMap? ?? const {};
|
||||||
final login = broadcaster['login'] as String? ?? '';
|
final login = broadcaster['login'] as String? ?? '';
|
||||||
|
final language = (node['language'] as String? ?? '').toLowerCase();
|
||||||
|
|
||||||
return StreamTwitch(
|
return StreamTwitch(
|
||||||
broadcaster['id'] as String? ?? '',
|
broadcaster['id'] as String? ?? '',
|
||||||
|
|
@ -305,6 +552,8 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
node['createdAt'] as String? ?? '',
|
node['createdAt'] as String? ?? '',
|
||||||
// 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,
|
||||||
|
const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
lib/apis/twitch_integrity.dart
Normal file
81
lib/apis/twitch_integrity.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
|
||||||
|
class TwitchIntegrity {
|
||||||
|
const TwitchIntegrity._();
|
||||||
|
|
||||||
|
/// Decodes the JSON payload embedded in an integrity token, if present.
|
||||||
|
static Map<String, dynamic>? decodePayload(String token) {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
|
for (final part in parts.skip(1)) {
|
||||||
|
if (part.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final normalized = base64Url.normalize(part);
|
||||||
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||||
|
final dynamic json = jsonDecode(decoded);
|
||||||
|
if (json is Map &&
|
||||||
|
(json.containsKey('is_bad_bot') ||
|
||||||
|
json.containsKey('client_id') ||
|
||||||
|
json.containsKey('device_id'))) {
|
||||||
|
return Map<String, dynamic>.from(json);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Try the next segment — token layout varies by version.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
|
||||||
|
static bool isBadBot(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
if (payload == null) return false;
|
||||||
|
final flag = payload['is_bad_bot'];
|
||||||
|
if (flag is bool) return flag;
|
||||||
|
if (flag is String) return flag.toLowerCase() == 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expiration in epoch millis from the payload, if present.
|
||||||
|
static int? expirationMs(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
if (payload == null) return null;
|
||||||
|
|
||||||
|
final exp = payload['exp'];
|
||||||
|
if (exp is int) {
|
||||||
|
// Already millis if large; seconds if typical JWT.
|
||||||
|
return exp > 100000000000 ? exp : exp * 1000;
|
||||||
|
}
|
||||||
|
if (exp is String) {
|
||||||
|
final asInt = int.tryParse(exp);
|
||||||
|
if (asInt != null) {
|
||||||
|
return asInt > 100000000000 ? asInt : asInt * 1000;
|
||||||
|
}
|
||||||
|
final dt = DateTime.tryParse(exp);
|
||||||
|
if (dt != null) return dt.millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device id the token was minted for (`X-Device-Id` must match).
|
||||||
|
static String? deviceId(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
final id = payload?['device_id'];
|
||||||
|
return id is String && id.isNotEmpty ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials captured from a browser/WebView GQL request.
|
||||||
|
class GqlIntegrityCredentials {
|
||||||
|
final String integrityToken;
|
||||||
|
final String deviceId;
|
||||||
|
final int expirationMs;
|
||||||
|
|
||||||
|
const GqlIntegrityCredentials({
|
||||||
|
required this.integrityToken,
|
||||||
|
required this.deviceId,
|
||||||
|
required this.expirationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -167,7 +167,10 @@ void main() async {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create and initialize the authentication store
|
// Create and initialize the authentication store
|
||||||
final authStore = AuthStore(twitchApi: twitchApiService);
|
final authStore = AuthStore(
|
||||||
|
twitchApi: twitchApiService,
|
||||||
|
twitchGqlApi: twitchGqlApiService,
|
||||||
|
);
|
||||||
|
|
||||||
// Add the auth interceptor to the Dio client after AuthStore creation
|
// Add the auth interceptor to the Dio client after AuthStore creation
|
||||||
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ class StreamTwitch {
|
||||||
final String startedAt;
|
final String startedAt;
|
||||||
final String thumbnailUrl;
|
final String thumbnailUrl;
|
||||||
|
|
||||||
|
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
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,
|
||||||
|
|
@ -24,8 +32,10 @@ class StreamTwitch {
|
||||||
this.title,
|
this.title,
|
||||||
this.viewerCount,
|
this.viewerCount,
|
||||||
this.startedAt,
|
this.startedAt,
|
||||||
this.thumbnailUrl,
|
this.thumbnailUrl, [
|
||||||
);
|
this.language = '',
|
||||||
|
this.tags = const [],
|
||||||
|
]);
|
||||||
|
|
||||||
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$StreamTwitchFromJson(json);
|
_$StreamTwitchFromJson(json);
|
||||||
|
|
@ -38,6 +48,20 @@ class StreamsTwitch {
|
||||||
|
|
||||||
const StreamsTwitch(this.data, this.pagination);
|
const StreamsTwitch(this.data, this.pagination);
|
||||||
|
|
||||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
|
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
|
||||||
_$StreamsTwitchFromJson(json);
|
final rawPagination = json['pagination'];
|
||||||
|
final pagination = <String, String>{};
|
||||||
|
if (rawPagination is Map) {
|
||||||
|
final cursor = rawPagination['cursor'];
|
||||||
|
if (cursor is String && cursor.isNotEmpty) {
|
||||||
|
pagination['cursor'] = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StreamsTwitch(
|
||||||
|
(json['data'] as List<dynamic>)
|
||||||
|
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
||||||
(json['viewer_count'] as num).toInt(),
|
(json['viewer_count'] as num).toInt(),
|
||||||
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['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||||
|
|
|
||||||
50
lib/models/stream_languages.dart
Normal file
50
lib/models/stream_languages.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/// ISO 639-1 codes offered in Settings for stream language filtering.
|
||||||
|
///
|
||||||
|
/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form.
|
||||||
|
class StreamLanguages {
|
||||||
|
StreamLanguages._();
|
||||||
|
|
||||||
|
static const all = <({String code, String label})>[
|
||||||
|
(code: 'de', label: 'Deutsch'),
|
||||||
|
(code: 'en', label: 'English'),
|
||||||
|
(code: 'es', label: 'Español'),
|
||||||
|
(code: 'fr', label: 'Français'),
|
||||||
|
(code: 'it', label: 'Italiano'),
|
||||||
|
(code: 'pt', label: 'Português'),
|
||||||
|
(code: 'pl', label: 'Polski'),
|
||||||
|
(code: 'ru', label: 'Русский'),
|
||||||
|
(code: 'tr', label: 'Türkçe'),
|
||||||
|
(code: 'nl', label: 'Nederlands'),
|
||||||
|
(code: 'sv', label: 'Svenska'),
|
||||||
|
(code: 'no', label: 'Norsk'),
|
||||||
|
(code: 'da', label: 'Dansk'),
|
||||||
|
(code: 'fi', label: 'Suomi'),
|
||||||
|
(code: 'cs', label: 'Čeština'),
|
||||||
|
(code: 'hu', label: 'Magyar'),
|
||||||
|
(code: 'ro', label: 'Română'),
|
||||||
|
(code: 'uk', label: 'Українська'),
|
||||||
|
(code: 'ja', label: '日本語'),
|
||||||
|
(code: 'ko', label: '한국어'),
|
||||||
|
(code: 'zh', label: '中文'),
|
||||||
|
(code: 'ar', label: 'العربية'),
|
||||||
|
(code: 'th', label: 'ไทย'),
|
||||||
|
(code: 'vi', label: 'Tiếng Việt'),
|
||||||
|
(code: 'id', label: 'Bahasa Indonesia'),
|
||||||
|
(code: 'hi', label: 'हिन्दी'),
|
||||||
|
(code: 'other', label: 'Other'),
|
||||||
|
];
|
||||||
|
|
||||||
|
static String labelFor(String code) {
|
||||||
|
for (final entry in all) {
|
||||||
|
if (entry.code == code) return entry.label;
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helix / settings codes → GQL `Language` enum values.
|
||||||
|
static List<String> toGqlEnums(Iterable<String> codes) => codes
|
||||||
|
.map((c) => c.trim().toLowerCase())
|
||||||
|
.where((c) => c.isNotEmpty)
|
||||||
|
.map((c) => c.toUpperCase())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.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:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/channel_info_header.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video.dart';
|
import 'package:frosty/screens/channel/video/native_video.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
|
|
@ -746,6 +747,14 @@ class _VideoChatState extends State<VideoChat>
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: SizedBox(),
|
child: SizedBox(),
|
||||||
),
|
),
|
||||||
|
ChannelInfoHeader(
|
||||||
|
videoStore: _videoStore,
|
||||||
|
authStore: context.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
displayName: widget.userName,
|
||||||
|
isInSharedChatMode:
|
||||||
|
_chatStore.isInSharedChatMode,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
63
lib/screens/channel/video/channel_info_header.dart
Normal file
63
lib/screens/channel/video/channel_info_header.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/follow_button.dart';
|
||||||
|
|
||||||
|
/// Channel metadata + follow control shown under the player (portrait).
|
||||||
|
class ChannelInfoHeader extends StatelessWidget {
|
||||||
|
final VideoPlayerInterface videoStore;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool isInSharedChatMode;
|
||||||
|
|
||||||
|
const ChannelInfoHeader({
|
||||||
|
super.key,
|
||||||
|
required this.videoStore,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.isInSharedChatMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
|
child: Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final streamInfo = videoStore.streamInfo;
|
||||||
|
final offline = streamInfo == null;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StreamInfoBar(
|
||||||
|
streamInfo: streamInfo,
|
||||||
|
offlineChannelInfo: videoStore.offlineChannelInfo,
|
||||||
|
displayName: displayName,
|
||||||
|
isCompact: true,
|
||||||
|
isOffline: offline,
|
||||||
|
isInSharedChatMode: isInSharedChatMode,
|
||||||
|
showTextShadows: false,
|
||||||
|
tooltipTriggerMode: TooltipTriggerMode.longPress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FollowButton(
|
||||||
|
authStore: authStore,
|
||||||
|
userId: userId,
|
||||||
|
displayName: displayName,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import 'package:frosty/models/playback_access_token.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/playback_error_recovery.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -131,6 +132,12 @@ abstract class NativeVideoStoreBase
|
||||||
@readonly
|
@readonly
|
||||||
var _blockingAds = false;
|
var _blockingAds = false;
|
||||||
|
|
||||||
|
/// Exposes [_blockingAds] via [VideoPlayerInterface.isBlockingAds]
|
||||||
|
/// (must live in the hand-written store — build_runner will strip a
|
||||||
|
/// hand-patched override from the `.g.dart`).
|
||||||
|
@override
|
||||||
|
bool get isBlockingAds => _blockingAds;
|
||||||
|
|
||||||
@readonly
|
@readonly
|
||||||
var _paused = true;
|
var _paused = true;
|
||||||
|
|
||||||
|
|
@ -677,26 +684,44 @@ abstract class NativeVideoStoreBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx on the HLS fetch means the current playback session is gone — a
|
// Mid-stream failures (expired HLS session, ExoPlayer "Source error"
|
||||||
// fresh GQL token + new query params can land on a different CDN edge
|
// after screen-off / network switch, CDN blips). A fresh token + reload
|
||||||
// with a working manifest. Skip the user-facing error and retry
|
// usually fixes these — only pin the Reload overlay once retries are
|
||||||
// immediately (handleRefresh updates _lastRefreshTime itself).
|
// spent. 4xx refreshes immediately; network/source waits briefly so the
|
||||||
if (statusCode != null &&
|
// interface can come back.
|
||||||
statusCode >= 400 &&
|
if (shouldAutoRecoverFromPlayerError(
|
||||||
statusCode < 500 &&
|
refreshCapReached: _refreshCapReached(),
|
||||||
!_refreshCapReached()) {
|
errorCode: errorCode,
|
||||||
|
)) {
|
||||||
if (_userPaused) {
|
if (_userPaused) {
|
||||||
// Don't yank a user-paused stream back to life — the refresh would
|
// Don't yank a user-paused stream back to life — the refresh would
|
||||||
// reload with autoplay. Defer until they resume.
|
// reload with autoplay. Defer until they resume.
|
||||||
_pendingRefreshOnResume = true;
|
_pendingRefreshOnResume = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final attempt = _totalRefreshAttempts + 1;
|
||||||
|
final delay = playerErrorRetryDelay(statusCode: statusCode);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh '
|
'NativeVideoStore: player error'
|
||||||
'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})',
|
'${statusCode != null ? ' (HTTP $statusCode)' : ''}'
|
||||||
|
'${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, '
|
||||||
|
'auto-refresh in ${delay.inMilliseconds}ms '
|
||||||
|
'(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})',
|
||||||
);
|
);
|
||||||
_totalRefreshAttempts++;
|
_totalRefreshAttempts = attempt;
|
||||||
_handleRefresh(userInitiated: false);
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_initRetryTimer?.cancel();
|
||||||
|
final generation = _initGeneration;
|
||||||
|
if (delay == Duration.zero) {
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
} else {
|
||||||
|
_initRetryTimer = Timer(delay, () {
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
// _handleRefresh parks behind _pendingRefreshOnResume when paused.
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1304,9 +1329,22 @@ abstract class NativeVideoStoreBase
|
||||||
if (_isInPipMode) return;
|
if (_isInPipMode) return;
|
||||||
_highLatencyCount = 0;
|
_highLatencyCount = 0;
|
||||||
updateStreamInfo(forceUpdate: true);
|
updateStreamInfo(forceUpdate: true);
|
||||||
if (!_userPaused && !_initializing && _controller != null) {
|
|
||||||
_controller!.play();
|
if (_userPaused || _initializing) return;
|
||||||
|
|
||||||
|
// After screen-off / a network drop the native player is often in an
|
||||||
|
// error state (ExoPlayer "Source error"). play() alone cannot revive a
|
||||||
|
// dead session — refresh like the Reload button would.
|
||||||
|
if (_error != null ||
|
||||||
|
_pendingRefreshOnResume ||
|
||||||
|
_controller == null ||
|
||||||
|
!_controllerInitialized) {
|
||||||
|
_pendingRefreshOnResume = false;
|
||||||
|
unawaited(_handleRefresh(userInitiated: false));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_controller!.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player
|
||||||
|
/// error instead of pinning the Reload overlay.
|
||||||
|
///
|
||||||
|
/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a
|
||||||
|
/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset]
|
||||||
|
/// has its own dispose+rebuild path and is excluded here.
|
||||||
|
bool shouldAutoRecoverFromPlayerError({
|
||||||
|
required bool refreshCapReached,
|
||||||
|
String? errorCode,
|
||||||
|
}) {
|
||||||
|
if (refreshCapReached) return false;
|
||||||
|
if (errorCode == 'mediaServicesWereReset') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delay before an automatic refresh for [statusCode].
|
||||||
|
///
|
||||||
|
/// Token/CDN 4xx can refresh immediately; network/source failures get a short
|
||||||
|
/// pause so the interface can come back after a switch or screen wake.
|
||||||
|
Duration playerErrorRetryDelay({int? statusCode}) {
|
||||||
|
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
|
||||||
|
return Duration.zero;
|
||||||
|
}
|
||||||
|
return VideoTimingConstants.initRetryDelay;
|
||||||
|
}
|
||||||
|
|
@ -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,12 +27,13 @@ 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(
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
final bool showCategory;
|
final bool showCategory;
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const LargeStreamCard({
|
const LargeStreamCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -28,6 +30,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
this.showCategory = true,
|
this.showCategory = true,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -95,6 +99,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
final bool showOfflineStatus;
|
final bool showOfflineStatus;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const OfflineChannelCard({
|
const OfflineChannelCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -22,6 +24,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
required this.showPinOption,
|
required this.showPinOption,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
this.showOfflineStatus = false,
|
this.showOfflineStatus = false,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -77,6 +81,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
userId: channelInfo.broadcasterId,
|
userId: channelInfo.broadcasterId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget {
|
||||||
final bool showCategory;
|
final bool showCategory;
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const StreamCard({
|
const StreamCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -34,6 +36,8 @@ class StreamCard extends StatelessWidget {
|
||||||
this.showCategory = true,
|
this.showCategory = true,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -224,6 +228,8 @@ class StreamCard extends StatelessWidget {
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,21 @@ abstract class ListStoreBase with Store {
|
||||||
/// 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;
|
||||||
|
|
||||||
|
/// When true, [dispose] will dispose [scrollController]. Shared controllers
|
||||||
|
/// from Home must not be disposed here.
|
||||||
|
final bool ownsScrollController;
|
||||||
|
|
||||||
/// The pagination cursor for the streams.
|
/// The pagination cursor for the streams.
|
||||||
String? _streamsCursor;
|
String? _streamsCursor;
|
||||||
|
|
||||||
|
/// When true, pages come from Helix (GQL cursor/integrity unavailable).
|
||||||
|
/// ASC Helix fill starts from the top; duplicates are skipped and the list
|
||||||
|
/// is re-sorted client-side.
|
||||||
|
var _helixFill = false;
|
||||||
|
|
||||||
|
/// Last page was served by GQL with server-side sort — append in API order.
|
||||||
|
var _lastPageWasGql = false;
|
||||||
|
|
||||||
/// The pagination cursor for the offline followed channels.
|
/// The pagination cursor for the offline followed channels.
|
||||||
String? _offlineChannelsCursor;
|
String? _offlineChannelsCursor;
|
||||||
|
|
||||||
|
|
@ -55,7 +67,8 @@ abstract class ListStoreBase with Store {
|
||||||
|
|
||||||
/// Returns whether or not there are more streams and loading status for pagination.
|
/// Returns whether or not there are more streams and loading status for pagination.
|
||||||
@computed
|
@computed
|
||||||
bool get hasMore => isLoading == false && _streamsCursor != null;
|
bool get hasMore =>
|
||||||
|
isLoading == false && (_streamsCursor != null || _helixFill);
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
|
|
@ -64,6 +77,12 @@ abstract class ListStoreBase with Store {
|
||||||
_isCategoryDetailsLoading ||
|
_isCategoryDetailsLoading ||
|
||||||
_isOfflineChannelsLoading;
|
_isOfflineChannelsLoading;
|
||||||
|
|
||||||
|
/// Selected stream language codes from settings (lowercase ISO 639-1).
|
||||||
|
List<String> get _languageFilter => settingsStore.streamLanguages
|
||||||
|
.map((l) => l.trim().toLowerCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
/// The loading status for pagination.
|
/// The loading status for pagination.
|
||||||
|
|
||||||
/// The list of the fetched streams.
|
/// The list of the fetched streams.
|
||||||
|
|
@ -156,6 +175,7 @@ abstract class ListStoreBase with Store {
|
||||||
|
|
||||||
ReactionDisposer? _pinnedStreamsReactionDisposer;
|
ReactionDisposer? _pinnedStreamsReactionDisposer;
|
||||||
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
||||||
|
ReactionDisposer? _languageFilterReactionDisposer;
|
||||||
|
|
||||||
ListStoreBase({
|
ListStoreBase({
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
|
|
@ -167,16 +187,10 @@ abstract class ListStoreBase with Store {
|
||||||
this.freeformTag,
|
this.freeformTag,
|
||||||
this.sortOrder = StreamViewerSort.viewersDesc,
|
this.sortOrder = StreamViewerSort.viewersDesc,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
|
this.ownsScrollController = false,
|
||||||
}) {
|
}) {
|
||||||
if (scrollController != null) {
|
if (scrollController != null) {
|
||||||
scrollController!.addListener(() {
|
scrollController!.addListener(_onScroll);
|
||||||
if (scrollController!.position.atEdge ||
|
|
||||||
scrollController!.position.outOfRange) {
|
|
||||||
showJumpButton = false;
|
|
||||||
} else {
|
|
||||||
showJumpButton = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listType == ListType.followed) {
|
if (listType == ListType.followed) {
|
||||||
|
|
@ -209,87 +223,241 @@ abstract class ListStoreBase with Store {
|
||||||
_getCategoryDetails();
|
_getCategoryDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload directory lists when the language filter or sort changes.
|
||||||
|
if (listType != ListType.followed) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
|
_languageFilterReactionDisposer = reaction(
|
||||||
|
(_) => [
|
||||||
|
...settingsStore.streamLanguages,
|
||||||
|
settingsStore.streamViewerSort,
|
||||||
|
],
|
||||||
|
(_) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
|
refreshStreams();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getStreams();
|
getStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final controller = scrollController;
|
||||||
|
if (controller == null || !controller.hasClients) return;
|
||||||
|
|
||||||
|
final position = controller.position;
|
||||||
|
if (position.atEdge || position.outOfRange) {
|
||||||
|
showJumpButton = false;
|
||||||
|
} else {
|
||||||
|
showJumpButton = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup pagination trigger (in addition to SliverList near-end builds).
|
||||||
|
if (hasMore &&
|
||||||
|
position.pixels >= position.maxScrollExtent - 800) {
|
||||||
|
getStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefer GQL for tag directories (both sorts) and for ascending Top/category.
|
||||||
|
bool get _preferGqlDirectory {
|
||||||
|
if (twitchGqlApi == null || _helixFill) return false;
|
||||||
|
if (listType == ListType.tag) return true;
|
||||||
|
return listType != ListType.followed &&
|
||||||
|
sortOrder == StreamViewerSort.viewersAsc;
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the streams based on the type and current cursor.
|
/// Fetches the streams based on the type and current cursor.
|
||||||
@action
|
@action
|
||||||
Future<void> getStreams() async {
|
Future<void> getStreams() async {
|
||||||
|
if (_isAllStreamsLoading) return;
|
||||||
|
|
||||||
_isAllStreamsLoading = true;
|
_isAllStreamsLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final StreamsTwitch newStreams;
|
final languages = _languageFilter;
|
||||||
switch (listType) {
|
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||||
case ListType.followed:
|
final useGql = _preferGqlDirectory;
|
||||||
newStreams = await twitchApi.getFollowedStreams(
|
_lastPageWasGql = false;
|
||||||
id: authStore.user.details!.id,
|
|
||||||
cursor: _streamsCursor,
|
StreamsTwitch? newStreams;
|
||||||
);
|
if (listType == ListType.followed) {
|
||||||
break;
|
newStreams = await twitchApi.getFollowedStreams(
|
||||||
case ListType.top:
|
id: authStore.user.details!.id,
|
||||||
// Helix is always viewer-desc; ascending needs GQL.
|
cursor: _streamsCursor,
|
||||||
if (sortOrder == StreamViewerSort.viewersAsc &&
|
);
|
||||||
twitchGqlApi != null) {
|
} else if (useGql) {
|
||||||
newStreams = await twitchGqlApi!.getStreams(
|
newStreams = await _fetchGqlDirectory(languages: languages);
|
||||||
sort: sortOrder,
|
if (newStreams != null) {
|
||||||
cursor: _streamsCursor,
|
_lastPageWasGql = true;
|
||||||
);
|
}
|
||||||
} else {
|
|
||||||
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ListType.category:
|
|
||||||
if (sortOrder == StreamViewerSort.viewersAsc &&
|
|
||||||
twitchGqlApi != null) {
|
|
||||||
newStreams = await twitchGqlApi!.getGameStreams(
|
|
||||||
gameId: categoryId!,
|
|
||||||
sort: sortOrder,
|
|
||||||
cursor: _streamsCursor,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
newStreams = await twitchApi.getStreamsUnderCategory(
|
|
||||||
gameId: categoryId!,
|
|
||||||
cursor: _streamsCursor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ListType.tag:
|
|
||||||
newStreams = await twitchGqlApi!.getStreams(
|
|
||||||
freeformTags: [freeformTag!],
|
|
||||||
sort: sortOrder,
|
|
||||||
cursor: _streamsCursor,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_streamsCursor == null) {
|
// Helix: Top/category DESC, or GQL unavailable / integrity fallback.
|
||||||
_allStreams = newStreams.data.asObservable();
|
newStreams ??= await twitchApi.getDirectoryStreams(
|
||||||
} else {
|
languages: languages,
|
||||||
_allStreams.addAll(newStreams.data);
|
gameId: listType == ListType.category ? categoryId : null,
|
||||||
|
freeformTag: listType == ListType.tag ? freeformTag : null,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50),
|
||||||
|
maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
final next = newStreams.pagination['cursor'];
|
||||||
|
final hasCursor = next != null && next.isNotEmpty;
|
||||||
|
|
||||||
|
// Empty Helix window with cursor left ⇒ keep scanning (sparse tags).
|
||||||
|
final isHelixContinuation = !_lastPageWasGql &&
|
||||||
|
(_streamsCursor != null ||
|
||||||
|
(_helixFill && _allStreams.isNotEmpty));
|
||||||
|
if (isHelixContinuation && newStreams.data.isEmpty) {
|
||||||
|
if (hasCursor) {
|
||||||
|
_streamsCursor = next;
|
||||||
|
_error = null;
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
|
await getStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GQL pages are already server-sorted — keep API order.
|
||||||
|
// Helix ASC fill: sort page + full list so late matches land correctly.
|
||||||
|
final pageData = (!_lastPageWasGql && isAsc)
|
||||||
|
? (List<StreamTwitch>.of(newStreams.data)..sort(
|
||||||
|
(a, b) => a.viewerCount.compareTo(b.viewerCount),
|
||||||
|
))
|
||||||
|
: newStreams.data;
|
||||||
|
|
||||||
|
if (_allStreams.isEmpty && !_helixFill) {
|
||||||
|
_allStreams = pageData.asObservable();
|
||||||
|
} else {
|
||||||
|
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||||
|
_allStreams.addAll(
|
||||||
|
pageData.where((s) => !existingIds.contains(s.userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_lastPageWasGql && isAsc && _allStreams.length > 1) {
|
||||||
|
final sorted = _allStreams.toList()
|
||||||
|
..sort((a, b) => a.viewerCount.compareTo(b.viewerCount));
|
||||||
|
_allStreams = sorted.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
_streamsCursor = hasCursor ? next : null;
|
||||||
|
if (_streamsCursor == null) {
|
||||||
|
_helixFill = false;
|
||||||
|
} else if (!_lastPageWasGql && isAsc) {
|
||||||
|
_helixFill = true;
|
||||||
}
|
}
|
||||||
_streamsCursor = newStreams.pagination['cursor'];
|
|
||||||
|
|
||||||
_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');
|
||||||
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
}
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
_error = e.message;
|
_error = e.message;
|
||||||
debugPrint('Streams ApiException: $e');
|
debugPrint('Streams ApiException: $e');
|
||||||
|
if (_allStreams.isNotEmpty) {
|
||||||
|
// Mid-scroll GQL failure: fall back to Helix fill when useful.
|
||||||
|
if (!_helixFill &&
|
||||||
|
(listType == ListType.tag || isAscDirectory)) {
|
||||||
|
_helixFill = true;
|
||||||
|
_streamsCursor = null;
|
||||||
|
_error = null;
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
|
await getStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Something went wrong loading streams';
|
_error = 'Something went wrong loading streams';
|
||||||
debugPrint('Streams error: $e');
|
debugPrint('Streams error: $e');
|
||||||
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isAllStreamsLoading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changes viewer sort and reloads from the first page.
|
/// GQL directory page with integrity retry. Returns null to trigger Helix.
|
||||||
|
Future<StreamsTwitch?> _fetchGqlDirectory({
|
||||||
|
required List<String> languages,
|
||||||
|
}) async {
|
||||||
|
final gql = twitchGqlApi;
|
||||||
|
if (gql == null) return null;
|
||||||
|
|
||||||
|
final langs = languages.isEmpty ? null : languages;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authStore.user.runWithGqlIntegrity(
|
||||||
|
gqlToken: authStore.gqlToken,
|
||||||
|
action: ({authToken, integrityToken, deviceId}) {
|
||||||
|
switch (listType) {
|
||||||
|
case ListType.tag:
|
||||||
|
return gql.getStreams(
|
||||||
|
freeformTags: [freeformTag!],
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.category:
|
||||||
|
return gql.getGameStreams(
|
||||||
|
gameId: categoryId!,
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.top:
|
||||||
|
return gql.getStreams(
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.followed:
|
||||||
|
throw StateError('GQL directory not used for followed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on IntegrityCheckException catch (e) {
|
||||||
|
debugPrint('Streams GQL integrity: $e — falling back to Helix');
|
||||||
|
_helixFill = true;
|
||||||
|
// Keep cursor null so Helix starts from the top (dedupe handles overlap).
|
||||||
|
_streamsCursor = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isAscDirectory =>
|
||||||
|
sortOrder == StreamViewerSort.viewersAsc &&
|
||||||
|
listType != ListType.followed;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
@ -367,6 +535,8 @@ abstract class ListStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
_streamsCursor = null;
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_lastPageWasGql = false;
|
||||||
await getStreams();
|
await getStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,8 +566,14 @@ abstract class ListStoreBase with Store {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pinnedStreamsReactionDisposer?.call();
|
_pinnedStreamsReactionDisposer?.call();
|
||||||
_offlineChannelsExpansionDisposer?.call();
|
_offlineChannelsExpansionDisposer?.call();
|
||||||
|
_languageFilterReactionDisposer?.call();
|
||||||
|
|
||||||
scrollController?.dispose();
|
if (scrollController != null) {
|
||||||
|
scrollController!.removeListener(_onScroll);
|
||||||
|
if (ownsScrollController) {
|
||||||
|
scrollController!.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,5 +582,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.
|
||||||
/// [ListType.tag] is the list of streams matching a freeform tag (GQL).
|
/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback).
|
||||||
enum ListType { followed, top, category, tag }
|
enum ListType { followed, top, category, tag }
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,18 @@ class _StreamsListState extends State<StreamsList>
|
||||||
freeformTag: widget.freeformTag,
|
freeformTag: widget.freeformTag,
|
||||||
sortOrder: widget.sortOrder,
|
sortOrder: widget.sortOrder,
|
||||||
scrollController: widget.scrollController ?? _scrollController,
|
scrollController: widget.scrollController ?? _scrollController,
|
||||||
|
ownsScrollController: widget.scrollController == null && widget.showJumpButton,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController?.dispose();
|
// ListStore disposes the controller when it owns it.
|
||||||
|
if (!_listStore.ownsScrollController) {
|
||||||
|
_scrollController?.dispose();
|
||||||
|
}
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_listStore.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,6 +260,12 @@ class _StreamsListState extends State<StreamsList>
|
||||||
: _listStore.streams;
|
: _listStore.streams;
|
||||||
|
|
||||||
final isFollowingTab = widget.listType == ListType.followed;
|
final isFollowingTab = widget.listType == ListType.followed;
|
||||||
|
// Refresh following lists after unfollow so live/offline rows drop.
|
||||||
|
void onFollowChanged(bool following) {
|
||||||
|
if (isFollowingTab && !following) {
|
||||||
|
_listStore.refreshStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
alignment: AlignmentDirectional.bottomCenter,
|
alignment: AlignmentDirectional.bottomCenter,
|
||||||
|
|
@ -315,6 +326,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
widget.categoryId == null,
|
widget.categoryId == null,
|
||||||
showPinOption: true,
|
showPinOption: true,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged:
|
||||||
|
onFollowChanged,
|
||||||
)
|
)
|
||||||
: StreamCard(
|
: StreamCard(
|
||||||
key: ValueKey(stream.userId),
|
key: ValueKey(stream.userId),
|
||||||
|
|
@ -326,6 +340,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
widget.categoryId == null,
|
widget.categoryId == null,
|
||||||
showPinOption: true,
|
showPinOption: true,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged:
|
||||||
|
onFollowChanged,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -368,6 +385,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
showCategory: widget.categoryId == null,
|
showCategory: widget.categoryId == null,
|
||||||
showPinOption: isFollowingTab,
|
showPinOption: isFollowingTab,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing:
|
||||||
|
isFollowingTab ? true : null,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
)
|
)
|
||||||
: StreamCard(
|
: StreamCard(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
|
|
@ -379,6 +399,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
showCategory: widget.categoryId == null,
|
showCategory: widget.categoryId == null,
|
||||||
showPinOption: isFollowingTab,
|
showPinOption: isFollowingTab,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing:
|
||||||
|
isFollowingTab ? true : null,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -427,6 +450,8 @@ class _StreamsListState extends State<StreamsList>
|
||||||
_listStore.offlineChannels[index],
|
_listStore.offlineChannels[index],
|
||||||
showPinOption: false,
|
showPinOption: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,12 +51,13 @@ 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
|
||||||
|
|
@ -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(
|
||||||
onChanged: (order) =>
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
setState(() => _sortOrder = order),
|
onChanged: (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,12 +42,13 @@ 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:
|
||||||
|
|
@ -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,11 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// import removed: flutter_colorpicker
|
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/models/stream_languages.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||||
import 'package:frosty/widgets/accent_color_setting.dart';
|
import 'package:frosty/widgets/accent_color_setting.dart';
|
||||||
import 'package:frosty/widgets/external_browser_setting.dart';
|
import 'package:frosty/widgets/external_browser_setting.dart';
|
||||||
// import removed: frosty/widgets/dialog.dart
|
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||||
import 'package:frosty/widgets/theme_selection_setting.dart';
|
import 'package:frosty/widgets/theme_selection_setting.dart';
|
||||||
|
|
@ -34,6 +33,48 @@ class GeneralSettings extends StatelessWidget {
|
||||||
value: settingsStore.showThumbnails,
|
value: settingsStore.showThumbnails,
|
||||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||||
),
|
),
|
||||||
|
const SectionHeader('Stream languages'),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
settingsStore.streamLanguages.isEmpty
|
||||||
|
? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).'
|
||||||
|
: 'Only streams in the selected languages appear in Top, category, and tag lists.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final entry in StreamLanguages.all)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(entry.label),
|
||||||
|
selected: settingsStore.streamLanguages.contains(entry.code),
|
||||||
|
onSelected: (selected) {
|
||||||
|
final next = [...settingsStore.streamLanguages];
|
||||||
|
if (selected) {
|
||||||
|
if (!next.contains(entry.code)) next.add(entry.code);
|
||||||
|
} else {
|
||||||
|
next.remove(entry.code);
|
||||||
|
}
|
||||||
|
settingsStore.streamLanguages = next;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settingsStore.streamLanguages.isNotEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
settingsStore.streamLanguages = const <String>[],
|
||||||
|
child: const Text('Clear language filter'),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SectionHeader('Links'),
|
const SectionHeader('Links'),
|
||||||
ExternalBrowserSetting(settingsStore: settingsStore),
|
ExternalBrowserSetting(settingsStore: settingsStore),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.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/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
|
|
@ -41,6 +42,9 @@ abstract class AuthBase with Store {
|
||||||
/// The Twitch API service for making requests.
|
/// The Twitch API service for making requests.
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
/// Web GQL client (follow/unfollow + playback tokens).
|
||||||
|
final TwitchGqlApi twitchGqlApi;
|
||||||
|
|
||||||
/// Timer used to retry authentication when offline or on transient failures.
|
/// Timer used to retry authentication when offline or on transient failures.
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
|
|
||||||
|
|
@ -259,9 +263,13 @@ abstract class AuthBase with Store {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthBase({required this.twitchApi}) {
|
AuthBase({
|
||||||
|
required this.twitchApi,
|
||||||
|
required this.twitchGqlApi,
|
||||||
|
}) {
|
||||||
user = UserStore(
|
user = UserStore(
|
||||||
twitchApi: twitchApi,
|
twitchApi: twitchApi,
|
||||||
|
twitchGqlApi: twitchGqlApi,
|
||||||
hasModeratorScopes: () => hasModeratorScopes,
|
hasModeratorScopes: () => hasModeratorScopes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -52,6 +53,24 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var launchUrlExternal = defaultLaunchUrlExternal;
|
var launchUrlExternal = defaultLaunchUrlExternal;
|
||||||
|
|
||||||
|
/// ISO 639-1 language codes for Top / category / tag stream lists.
|
||||||
|
/// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account.
|
||||||
|
static const defaultStreamLanguages = <String>['de'];
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultStreamLanguages)
|
||||||
|
@observable
|
||||||
|
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;
|
||||||
|
|
@ -61,6 +80,8 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showThumbnails = defaultShowThumbnails;
|
showThumbnails = defaultShowThumbnails;
|
||||||
|
|
||||||
launchUrlExternal = defaultLaunchUrlExternal;
|
launchUrlExternal = defaultLaunchUrlExternal;
|
||||||
|
streamLanguages = defaultStreamLanguages;
|
||||||
|
streamViewerSort = defaultStreamViewerSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Video Settings
|
// * Video Settings
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..showThumbnails = json['showThumbnails'] as bool? ?? true
|
..showThumbnails = json['showThumbnails'] as bool? ?? true
|
||||||
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
|
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
|
||||||
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
|
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
|
||||||
|
..streamLanguages =
|
||||||
|
(json['streamLanguages'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
['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
|
||||||
|
|
@ -104,6 +116,8 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showThumbnails': instance.showThumbnails,
|
'showThumbnails': instance.showThumbnails,
|
||||||
'largeStreamCard': instance.largeStreamCard,
|
'largeStreamCard': instance.largeStreamCard,
|
||||||
'launchUrlExternal': instance.launchUrlExternal,
|
'launchUrlExternal': instance.launchUrlExternal,
|
||||||
|
'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,
|
||||||
|
|
@ -159,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',
|
||||||
|
|
@ -278,6 +297,42 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$streamLanguagesAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.streamLanguages',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get streamLanguages {
|
||||||
|
_$streamLanguagesAtom.reportRead();
|
||||||
|
return super.streamLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set streamLanguages(List<String> value) {
|
||||||
|
_$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () {
|
||||||
|
super.streamLanguages = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
@ -1261,6 +1316,8 @@ accentColor: ${accentColor},
|
||||||
showThumbnails: ${showThumbnails},
|
showThumbnails: ${showThumbnails},
|
||||||
largeStreamCard: ${largeStreamCard},
|
largeStreamCard: ${largeStreamCard},
|
||||||
launchUrlExternal: ${launchUrlExternal},
|
launchUrlExternal: ${launchUrlExternal},
|
||||||
|
streamLanguages: ${streamLanguages},
|
||||||
|
streamViewerSort: ${streamViewerSort},
|
||||||
showVideo: ${showVideo},
|
showVideo: ${showVideo},
|
||||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||||
useTextureRendering: ${useTextureRendering},
|
useTextureRendering: ${useTextureRendering},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
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/apis/twitch_integrity.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
part 'user_store.g.dart';
|
part 'user_store.g.dart';
|
||||||
|
|
||||||
|
|
@ -8,6 +14,7 @@ class UserStore = UserStoreBase with _$UserStore;
|
||||||
|
|
||||||
abstract class UserStoreBase with Store {
|
abstract class UserStoreBase with Store {
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
final TwitchGqlApi twitchGqlApi;
|
||||||
|
|
||||||
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
|
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
|
||||||
final bool Function() hasModeratorScopes;
|
final bool Function() hasModeratorScopes;
|
||||||
|
|
@ -24,10 +31,20 @@ abstract class UserStoreBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
var _moderatedChannels = ObservableList<String>();
|
var _moderatedChannels = ObservableList<String>();
|
||||||
|
|
||||||
|
/// Cached follow state for channels queried this session (id → following).
|
||||||
|
final _followCache = ObservableMap<String, bool>();
|
||||||
|
|
||||||
|
static const _deviceIdKey = 'twitch_gql_device_id';
|
||||||
|
|
||||||
|
String? _deviceId;
|
||||||
|
String? _integrityToken;
|
||||||
|
int _integrityExpirationMs = 0;
|
||||||
|
|
||||||
ReactionDisposer? _disposeReaction;
|
ReactionDisposer? _disposeReaction;
|
||||||
|
|
||||||
UserStoreBase({
|
UserStoreBase({
|
||||||
required this.twitchApi,
|
required this.twitchApi,
|
||||||
|
required this.twitchGqlApi,
|
||||||
required this.hasModeratorScopes,
|
required this.hasModeratorScopes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,6 +78,172 @@ abstract class UserStoreBase with Store {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether we already know follow state for [channelId] this session.
|
||||||
|
bool? cachedIsFollowing(String channelId) => _followCache[channelId];
|
||||||
|
|
||||||
|
@action
|
||||||
|
Future<bool> isFollowingChannel(String channelId) async {
|
||||||
|
final cached = _followCache[channelId];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final userId = _details?.id;
|
||||||
|
if (userId == null) return false;
|
||||||
|
|
||||||
|
final following = await twitchApi.isFollowingChannel(
|
||||||
|
userId: userId,
|
||||||
|
broadcasterId: channelId,
|
||||||
|
);
|
||||||
|
_followCache[channelId] = following;
|
||||||
|
return following;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token.
|
||||||
|
@action
|
||||||
|
Future<void> follow({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
await _followMutation(
|
||||||
|
targetId: targetId,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
follow: true,
|
||||||
|
);
|
||||||
|
_followCache[targetId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfollows [targetId] via web GQL.
|
||||||
|
@action
|
||||||
|
Future<void> unfollow({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
await _followMutation(
|
||||||
|
targetId: targetId,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
follow: false,
|
||||||
|
);
|
||||||
|
_followCache[targetId] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _followMutation({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
required bool follow,
|
||||||
|
}) async {
|
||||||
|
await runWithGqlIntegrity(
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
action: ({authToken, integrityToken, deviceId}) {
|
||||||
|
if (follow) {
|
||||||
|
return twitchGqlApi.followUser(
|
||||||
|
targetId: targetId,
|
||||||
|
authToken: authToken!,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return twitchGqlApi.unfollowUser(
|
||||||
|
targetId: targetId,
|
||||||
|
authToken: authToken!,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a GQL call with optional Client-Integrity; refreshes once on
|
||||||
|
/// [IntegrityCheckException] when a web [gqlToken] is available.
|
||||||
|
///
|
||||||
|
/// Dio-minted tokens that are `is_bad_bot` are discarded; callers should
|
||||||
|
/// then capture credentials via a WebView and [applyWebIntegrity].
|
||||||
|
Future<T> runWithGqlIntegrity<T>({
|
||||||
|
required String? gqlToken,
|
||||||
|
required Future<T> Function({
|
||||||
|
required String? authToken,
|
||||||
|
required String? integrityToken,
|
||||||
|
required String? deviceId,
|
||||||
|
}) action,
|
||||||
|
}) async {
|
||||||
|
final deviceId = await _ensureDeviceId();
|
||||||
|
final authToken =
|
||||||
|
gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null;
|
||||||
|
|
||||||
|
Future<T> run(String? integrityToken) => action(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: _deviceId ?? deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await run(_validIntegrityToken());
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (authToken == null) {
|
||||||
|
clearIntegrity();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final integrity = await twitchGqlApi.fetchIntegrityToken(
|
||||||
|
authToken: authToken,
|
||||||
|
deviceId: _deviceId ?? deviceId,
|
||||||
|
);
|
||||||
|
_integrityToken = integrity.token;
|
||||||
|
_integrityExpirationMs = integrity.expirationMs;
|
||||||
|
return await run(integrity.token);
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
clearIntegrity();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]).
|
||||||
|
Future<void> applyWebIntegrity(GqlIntegrityCredentials credentials) async {
|
||||||
|
if (TwitchIntegrity.isBadBot(credentials.integrityToken)) {
|
||||||
|
clearIntegrity();
|
||||||
|
throw const IntegrityCheckException(
|
||||||
|
'Twitch integrity token flagged as bad bot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_integrityToken = credentials.integrityToken;
|
||||||
|
_integrityExpirationMs = credentials.expirationMs;
|
||||||
|
_deviceId = credentials.deviceId;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_deviceIdKey, credentials.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearIntegrity() {
|
||||||
|
_integrityToken = null;
|
||||||
|
_integrityExpirationMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validIntegrityToken() {
|
||||||
|
if (_integrityToken == null) return null;
|
||||||
|
if (TwitchIntegrity.isBadBot(_integrityToken!)) {
|
||||||
|
clearIntegrity();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _integrityToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _ensureDeviceId() async {
|
||||||
|
if (_deviceId != null) return _deviceId!;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var id = prefs.getString(_deviceIdKey);
|
||||||
|
if (id == null || id.isEmpty) {
|
||||||
|
final random = Random.secure();
|
||||||
|
id = List.generate(
|
||||||
|
32,
|
||||||
|
(_) => random.nextInt(16).toRadixString(16),
|
||||||
|
).join();
|
||||||
|
await prefs.setString(_deviceIdKey, id);
|
||||||
|
}
|
||||||
|
_deviceId = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> block({
|
Future<void> block({
|
||||||
required String targetId,
|
required String targetId,
|
||||||
|
|
@ -161,6 +344,8 @@ abstract class UserStoreBase with Store {
|
||||||
_details = null;
|
_details = null;
|
||||||
_blockedUsers.clear();
|
_blockedUsers.clear();
|
||||||
_moderatedChannels.clear();
|
_moderatedChannels.clear();
|
||||||
|
_followCache.clear();
|
||||||
|
clearIntegrity();
|
||||||
_disposeReaction?.call();
|
_disposeReaction?.call();
|
||||||
_disposeReaction = null;
|
_disposeReaction = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store {
|
||||||
return _$initAsyncAction.run(() => super.init());
|
return _$initAsyncAction.run(() => super.init());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$isFollowingChannelAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.isFollowingChannel',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isFollowingChannel(String channelId) {
|
||||||
|
return _$isFollowingChannelAsyncAction.run(
|
||||||
|
() => super.isFollowingChannel(channelId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$followAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.follow',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> follow({required String targetId, required String gqlToken}) {
|
||||||
|
return _$followAsyncAction.run(
|
||||||
|
() => super.follow(targetId: targetId, gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$unfollowAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.unfollow',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unfollow({required String targetId, required String gqlToken}) {
|
||||||
|
return _$unfollowAsyncAction.run(
|
||||||
|
() => super.unfollow(targetId: targetId, gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
late final _$blockAsyncAction = AsyncAction(
|
late final _$blockAsyncAction = AsyncAction(
|
||||||
'UserStoreBase.block',
|
'UserStoreBase.block',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
206
lib/widgets/follow_button.dart
Normal file
206
lib/widgets/follow_button.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
|
|
||||||
|
/// Compact Follow / Unfollow control for channel headers.
|
||||||
|
class FollowButton extends StatefulWidget {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
|
const FollowButton({
|
||||||
|
super.key,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FollowButton> createState() => _FollowButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FollowButtonState extends State<FollowButton> {
|
||||||
|
late final FollowChannelController _controller = FollowChannelController(
|
||||||
|
authStore: widget.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
|
onChanged: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPressed() async {
|
||||||
|
if (!_controller.canFollow ||
|
||||||
|
_controller.isFollowing == null ||
|
||||||
|
_controller.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _controller.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: wasFollowing,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptLinkWebSession() {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Web session required',
|
||||||
|
message:
|
||||||
|
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showResultSnack(bool nowFollowing) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(
|
||||||
|
message: nowFollowing
|
||||||
|
? 'Followed ${widget.displayName}'
|
||||||
|
: 'Unfollowed ${widget.displayName}',
|
||||||
|
centered: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_controller.shouldShow) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = _controller.isFollowing == true;
|
||||||
|
final busy = _controller.loading || _controller.actionInProgress;
|
||||||
|
|
||||||
|
return FilledButton.tonal(
|
||||||
|
onPressed: busy ? null : _onPressed,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
following
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
Text(following ? 'Unfollow' : 'Follow'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
lib/widgets/follow_channel_controller.dart
Normal file
150
lib/widgets/follow_channel_controller.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
|
/// Shared follow/unfollow state + GQL mutations for channel UI.
|
||||||
|
///
|
||||||
|
/// Owns loading/action flags and the session cache via [AuthStore.user].
|
||||||
|
/// Callers handle UI (integrity WebView, login prompt, snackbars).
|
||||||
|
class FollowChannelController {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final void Function() onChanged;
|
||||||
|
|
||||||
|
bool? isFollowing;
|
||||||
|
bool loading = false;
|
||||||
|
bool actionInProgress = false;
|
||||||
|
|
||||||
|
FollowChannelController({
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isSelf =>
|
||||||
|
authStore.user.details?.id != null &&
|
||||||
|
authStore.user.details!.id == userId;
|
||||||
|
|
||||||
|
bool get canFollow => authStore.isLoggedIn && !isSelf;
|
||||||
|
|
||||||
|
/// Whether the follow control should be visible (loading or known state).
|
||||||
|
bool get shouldShow => canFollow && (loading || isFollowing != null);
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
if (!canFollow) return;
|
||||||
|
if (initialIsFollowing != null) {
|
||||||
|
isFollowing = initialIsFollowing;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadFollowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadFollowState() async {
|
||||||
|
final cached = authStore.user.cachedIsFollowing(userId);
|
||||||
|
if (cached != null) {
|
||||||
|
isFollowing = cached;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
onChanged();
|
||||||
|
try {
|
||||||
|
final following = await authStore.user.isFollowingChannel(userId);
|
||||||
|
isFollowing = following;
|
||||||
|
} catch (_) {
|
||||||
|
// Leave isFollowing null so the control stays hidden.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles follow. Returns the new following state on success.
|
||||||
|
///
|
||||||
|
/// Throws [IntegrityCheckException] when a WebView integrity mint is needed,
|
||||||
|
/// [ApiException] for API errors, or [StateError] when not ready / no token.
|
||||||
|
Future<bool> toggle() async {
|
||||||
|
if (actionInProgress || isFollowing == null) {
|
||||||
|
throw StateError('Follow toggle not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = isFollowing!;
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _runMutation(following: following, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !following;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a WebView integrity mint, apply credentials and retry [toggle]'s
|
||||||
|
/// intended mutation (still based on the pre-toggle [wasFollowing] state).
|
||||||
|
Future<bool> retryAfterIntegrity({
|
||||||
|
required bool wasFollowing,
|
||||||
|
required GqlIntegrityCredentials credentials,
|
||||||
|
}) async {
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.user.applyWebIntegrity(credentials);
|
||||||
|
await _runMutation(following: wasFollowing, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !wasFollowing;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runMutation({
|
||||||
|
required bool following,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
if (following) {
|
||||||
|
await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
} else {
|
||||||
|
await authStore.user.follow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearActionInProgress() {
|
||||||
|
if (!actionInProgress) return;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when follow needs a linked Twitch web (GQL) session.
|
||||||
|
class FollowNeedsWebSessionException implements Exception {
|
||||||
|
const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
232
lib/widgets/integrity_webview_dialog.dart
Normal file
232
lib/widgets/integrity_webview_dialog.dart
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a
|
||||||
|
/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`).
|
||||||
|
///
|
||||||
|
/// Returns credentials, or null if the user cancels / capture times out.
|
||||||
|
Future<GqlIntegrityCredentials?> showIntegrityWebViewDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required String gqlToken,
|
||||||
|
}) {
|
||||||
|
return showDialog<GqlIntegrityCredentials>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntegrityWebViewDialog extends StatefulWidget {
|
||||||
|
final String gqlToken;
|
||||||
|
|
||||||
|
const IntegrityWebViewDialog({super.key, required this.gqlToken});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IntegrityWebViewDialog> createState() => _IntegrityWebViewDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IntegrityWebViewDialogState extends State<IntegrityWebViewDialog> {
|
||||||
|
static const _captureTimeout = Duration(seconds: 45);
|
||||||
|
|
||||||
|
late final WebViewController _controller;
|
||||||
|
Timer? _timeout;
|
||||||
|
var _captured = false;
|
||||||
|
var _status = 'Verifying with Twitch…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setUserAgent(
|
||||||
|
Platform.isIOS
|
||||||
|
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||||
|
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 '
|
||||||
|
'Mobile/15E148 Safari/604.1'
|
||||||
|
: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
|
||||||
|
)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'FrostyIntegrity',
|
||||||
|
onMessageReceived: _onIntegrityMessage,
|
||||||
|
)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onPageStarted: (_) => _injectHooks(),
|
||||||
|
onPageFinished: (_) => _injectHooks(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_prepareAndLoad();
|
||||||
|
_timeout = Timer(_captureTimeout, () {
|
||||||
|
if (!mounted || _captured) return;
|
||||||
|
setState(() => _status = 'Timed out waiting for Twitch. Try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareAndLoad() async {
|
||||||
|
try {
|
||||||
|
final cookies = WebViewCookieManager();
|
||||||
|
await cookies.setCookie(
|
||||||
|
WebViewCookie(
|
||||||
|
name: 'auth-token',
|
||||||
|
value: widget.gqlToken,
|
||||||
|
domain: '.twitch.tv',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: cookie set failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL
|
||||||
|
// requests that carry a browser-valid Client-Integrity header.
|
||||||
|
await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _injectHooks() async {
|
||||||
|
// Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic.
|
||||||
|
const script = r'''
|
||||||
|
(function () {
|
||||||
|
if (window.__frostyIntegrityHooked) return;
|
||||||
|
window.__frostyIntegrityHooked = true;
|
||||||
|
|
||||||
|
function report(headers) {
|
||||||
|
if (!headers || !window.FrostyIntegrity) return;
|
||||||
|
var integrity = headers['Client-Integrity'] || headers['client-integrity'];
|
||||||
|
if (!integrity) return;
|
||||||
|
var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || '';
|
||||||
|
var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || '';
|
||||||
|
window.FrostyIntegrity.postMessage(JSON.stringify({
|
||||||
|
integrity: integrity,
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientId: clientId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToObject(headers) {
|
||||||
|
var out = {};
|
||||||
|
if (!headers) return out;
|
||||||
|
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||||
|
headers.forEach(function (value, key) { out[key] = value; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (typeof headers === 'object') {
|
||||||
|
Object.keys(headers).forEach(function (key) { out[key] = headers[key]; });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalFetch = window.fetch;
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
try {
|
||||||
|
report(headersToObject(init && init.headers));
|
||||||
|
} catch (e) {}
|
||||||
|
return originalFetch.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
var originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader;
|
||||||
|
var originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function () {
|
||||||
|
this.__frostyHeaders = {};
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
|
||||||
|
try { this.__frostyHeaders[key] = value; } catch (e) {}
|
||||||
|
return originalSetHeader.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.send = function () {
|
||||||
|
try { report(this.__frostyHeaders || {}); } catch (e) {}
|
||||||
|
return originalSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _controller.runJavaScript(script);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: hook inject failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIntegrityMessage(JavaScriptMessage message) {
|
||||||
|
if (_captured || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(message.message) as Map<String, dynamic>;
|
||||||
|
final integrity = map['integrity'] as String?;
|
||||||
|
if (integrity == null || integrity.isEmpty) return;
|
||||||
|
if (TwitchIntegrity.isBadBot(integrity)) {
|
||||||
|
debugPrint('IntegrityWebView: ignoring bad_bot token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fromHeader = map['deviceId'] as String?;
|
||||||
|
final fromToken = TwitchIntegrity.deviceId(integrity);
|
||||||
|
final deviceId = (fromHeader != null && fromHeader.isNotEmpty)
|
||||||
|
? fromHeader
|
||||||
|
: (fromToken ?? '');
|
||||||
|
if (deviceId.isEmpty) {
|
||||||
|
debugPrint('IntegrityWebView: missing device id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiration = TwitchIntegrity.expirationMs(integrity) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds;
|
||||||
|
|
||||||
|
_captured = true;
|
||||||
|
_timeout?.cancel();
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
GqlIntegrityCredentials(
|
||||||
|
integrityToken: integrity,
|
||||||
|
deviceId: deviceId,
|
||||||
|
expirationMs: expiration,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: parse failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timeout?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirm with Twitch'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
height: height.clamp(280.0, 520.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(_status),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: WebViewWidget(controller: _controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
class UserActionsModal extends StatelessWidget {
|
class UserActionsModal extends StatefulWidget {
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
final String name;
|
final String name;
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
|
|
@ -14,6 +20,12 @@ class UserActionsModal extends StatelessWidget {
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
|
||||||
|
/// When set (e.g. Following tab), skip the Helix lookup and show this state.
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
|
||||||
|
/// Called after a successful follow (`true`) or unfollow (`false`).
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
/// Optional moderation actions (shown at the top) for chat contexts. Kept as
|
/// Optional moderation actions (shown at the top) for chat contexts. Kept as
|
||||||
/// an injected widget so this generic modal stays chat-agnostic.
|
/// an injected widget so this generic modal stays chat-agnostic.
|
||||||
final Widget? moderationActions;
|
final Widget? moderationActions;
|
||||||
|
|
@ -26,51 +38,224 @@ class UserActionsModal extends StatelessWidget {
|
||||||
required this.userId,
|
required this.userId,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
this.moderationActions,
|
this.moderationActions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserActionsModal> createState() => _UserActionsModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserActionsModalState extends State<UserActionsModal> {
|
||||||
|
late final FollowChannelController _follow = FollowChannelController(
|
||||||
|
authStore: widget.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
|
onChanged: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_follow.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFollow() async {
|
||||||
|
if (!_follow.canFollow ||
|
||||||
|
_follow.isFollowing == null ||
|
||||||
|
_follow.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _follow.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _follow.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_popWithSnack(
|
||||||
|
nowFollowing
|
||||||
|
? 'Followed ${widget.name}'
|
||||||
|
: 'Unfollowed ${widget.name}',
|
||||||
|
);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.name}'
|
||||||
|
: 'Could not follow ${widget.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
_follow.clearActionInProgress();
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
_follow.clearActionInProgress();
|
||||||
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _follow.retryAfterIntegrity(
|
||||||
|
wasFollowing: wasFollowing,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_popWithSnack(
|
||||||
|
nowFollowing
|
||||||
|
? 'Followed ${widget.name}'
|
||||||
|
: 'Unfollowed ${widget.name}',
|
||||||
|
);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.name}'
|
||||||
|
: 'Could not follow ${widget.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _popWithSnack(String message) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
Navigator.pop(context);
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptLinkWebSession() {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Web session required',
|
||||||
|
message:
|
||||||
|
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final showFollowTile = _follow.shouldShow;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
primary: false,
|
primary: false,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
?moderationActions,
|
?widget.moderationActions,
|
||||||
if (showPinOption)
|
if (widget.showPinOption)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.push_pin_outlined),
|
leading: const Icon(Icons.push_pin_outlined),
|
||||||
title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'),
|
title: Text(
|
||||||
|
'${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}',
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (isPinned == true) {
|
if (widget.isPinned == true) {
|
||||||
context.read<SettingsStore>().pinnedChannelIds = [
|
context.read<SettingsStore>().pinnedChannelIds = [
|
||||||
...context.read<SettingsStore>().pinnedChannelIds
|
...context.read<SettingsStore>().pinnedChannelIds
|
||||||
..remove(userId),
|
..remove(widget.userId),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
context.read<SettingsStore>().pinnedChannelIds = [
|
context.read<SettingsStore>().pinnedChannelIds = [
|
||||||
...context.read<SettingsStore>().pinnedChannelIds,
|
...context.read<SettingsStore>().pinnedChannelIds,
|
||||||
userId,
|
widget.userId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (authStore.isLoggedIn)
|
if (showFollowTile)
|
||||||
|
ListTile(
|
||||||
|
leading: _follow.loading || _follow.actionInProgress
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_follow.isFollowing == true
|
||||||
|
? Icons.heart_broken_rounded
|
||||||
|
: Icons.favorite_rounded,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_follow.isFollowing == true
|
||||||
|
? 'Unfollow ${widget.name}'
|
||||||
|
: 'Follow ${widget.name}',
|
||||||
|
),
|
||||||
|
enabled: !_follow.loading && !_follow.actionInProgress,
|
||||||
|
onTap: _toggleFollow,
|
||||||
|
),
|
||||||
|
if (widget.authStore.isLoggedIn)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.block_rounded),
|
leading: const Icon(Icons.block_rounded),
|
||||||
onTap: () => authStore
|
onTap: () => widget.authStore
|
||||||
.showBlockDialog(
|
.showBlockDialog(
|
||||||
context,
|
context,
|
||||||
targetUser: name,
|
targetUser: widget.name,
|
||||||
targetUserId: userId,
|
targetUserId: widget.userId,
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
title: Text('Block $name'),
|
title: Text('Block ${widget.name}'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.outlined_flag_rounded),
|
leading: const Icon(Icons.outlined_flag_rounded),
|
||||||
|
|
@ -101,7 +286,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
),
|
),
|
||||||
title: Text('Report $name'),
|
title: Text('Report ${widget.name}'),
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -118,7 +303,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..loadRequest(
|
..loadRequest(
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'https://www.twitch.tv/$userLogin/report',
|
'https://www.twitch.tv/${widget.userLogin}/report',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
|
|
@ -154,7 +339,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text('Report $name'),
|
title: Text('Report ${widget.name}'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.2+72
|
version: 5.3.1+81
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|
|
||||||
|
|
@ -587,4 +587,55 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isFollowingChannel', () {
|
||||||
|
test('returns true when Helix lists the broadcaster', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/channels/followed',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'total': 1,
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'broadcaster_login': 'streamer',
|
||||||
|
'broadcaster_name': 'Streamer',
|
||||||
|
'followed_at': '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'pagination': <String, dynamic>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '12345',
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when not following', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/channels/followed',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'total': 0,
|
||||||
|
'data': <dynamic>[],
|
||||||
|
'pagination': <String, dynamic>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '12345',
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
test/apis/twitch_gql_follow_test.dart
Normal file
102
test/apis/twitch_gql_follow_test.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Dio dio;
|
||||||
|
late DioAdapter dioAdapter;
|
||||||
|
late TwitchGqlApi api;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
dio = Dio(BaseOptions());
|
||||||
|
dioAdapter = DioAdapter(dio: dio);
|
||||||
|
api = TwitchGqlApi(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('followUser', () {
|
||||||
|
test('posts FollowButton_FollowUser persisted mutation', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'followUser': {
|
||||||
|
'follow': {
|
||||||
|
'user': {'id': '999', 'displayName': 'Streamer'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.followUser(targetId: '999', authToken: 'web-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws IntegrityCheckException on integrity errors', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'errors': [
|
||||||
|
{'message': 'failed integrity check'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.followUser(targetId: '999', authToken: 'web-token'),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('unfollowUser', () {
|
||||||
|
test('posts FollowButton_UnfollowUser persisted mutation', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'unfollowUser': {
|
||||||
|
'follow': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.unfollowUser(targetId: '999', authToken: 'web-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fetchIntegrityToken', () {
|
||||||
|
test('rejects bad_bot tokens', () async {
|
||||||
|
final payload = base64Url.encode(
|
||||||
|
utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'device_id': 'dev',
|
||||||
|
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/integrity',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'token': 'v4.public.$payload.sig',
|
||||||
|
'expiration': DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
129
test/apis/twitch_gql_streams_test.dart
Normal file
129
test/apis/twitch_gql_streams_test.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Dio dio;
|
||||||
|
late DioAdapter dioAdapter;
|
||||||
|
late TwitchGqlApi api;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
dio = Dio(BaseOptions());
|
||||||
|
dioAdapter = DioAdapter(dio: dio);
|
||||||
|
api = TwitchGqlApi(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, Object?> streamEdge({
|
||||||
|
required String id,
|
||||||
|
required String login,
|
||||||
|
required int viewers,
|
||||||
|
String? cursor,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'cursor': cursor ?? 'cursor-$id',
|
||||||
|
'node': {
|
||||||
|
'id': id,
|
||||||
|
'title': '$login live',
|
||||||
|
'viewersCount': viewers,
|
||||||
|
'createdAt': '2026-01-01T00:00:00Z',
|
||||||
|
'language': 'en',
|
||||||
|
'game': {'id': '1', 'name': 'Game'},
|
||||||
|
'broadcaster': {
|
||||||
|
'id': id,
|
||||||
|
'login': login,
|
||||||
|
'displayName': login,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
group('getStreams', () {
|
||||||
|
test('parses freeform tag page and cursor', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'streams': {
|
||||||
|
'edges': [
|
||||||
|
streamEdge(id: '1', login: 'a', viewers: 10),
|
||||||
|
streamEdge(id: '2', login: 'b', viewers: 5),
|
||||||
|
],
|
||||||
|
'pageInfo': {'hasNextPage': true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await api.getStreams(
|
||||||
|
freeformTags: ['Deutsch'],
|
||||||
|
sort: StreamViewerSort.viewersAsc,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(page.data.map((s) => s.userLogin), ['a', 'b']);
|
||||||
|
expect(page.data.map((s) => s.viewerCount), [10, 5]);
|
||||||
|
expect(page.pagination['cursor'], 'cursor-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws IntegrityCheckException on integrity errors', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'errors': [
|
||||||
|
{'message': 'failed integrity check'},
|
||||||
|
],
|
||||||
|
'data': {'streams': null},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.getStreams(
|
||||||
|
freeformTags: ['Deutsch'],
|
||||||
|
cursor: 'page-2',
|
||||||
|
authToken: 'web',
|
||||||
|
integrityToken: 'stale',
|
||||||
|
deviceId: 'device',
|
||||||
|
),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends Client-Integrity and X-Device-Id when provided', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'streams': {
|
||||||
|
'edges': [streamEdge(id: '1', login: 'a', viewers: 1)],
|
||||||
|
'pageInfo': {'hasNextPage': false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
headers: {
|
||||||
|
'Client-ID': Matchers.any,
|
||||||
|
'Content-Type': Matchers.any,
|
||||||
|
'User-Agent': Matchers.any,
|
||||||
|
'Authorization': 'OAuth web-token',
|
||||||
|
'X-Device-Id': 'dev-123',
|
||||||
|
'Client-Integrity': 'integrity-token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await api.getStreams(
|
||||||
|
freeformTags: ['English'],
|
||||||
|
sort: StreamViewerSort.viewersDesc,
|
||||||
|
cursor: 'after-1',
|
||||||
|
authToken: 'web-token',
|
||||||
|
integrityToken: 'integrity-token',
|
||||||
|
deviceId: 'dev-123',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(page.data, hasLength(1));
|
||||||
|
expect(page.pagination, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
42
test/apis/twitch_integrity_test.dart
Normal file
42
test/apis/twitch_integrity_test.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
|
||||||
|
String _tokenFor(Map<String, Object?> payload) {
|
||||||
|
final encoded = base64Url.encode(utf8.encode(jsonEncode(payload)));
|
||||||
|
return 'v4.public.$encoded.fakesig';
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TwitchIntegrity', () {
|
||||||
|
test('detects is_bad_bot string flag', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||||
|
'device_id': 'abc123',
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'exp': '2099-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||||
|
expect(TwitchIntegrity.deviceId(token), 'abc123');
|
||||||
|
expect(TwitchIntegrity.expirationMs(token), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects is_bad_bot bool flag', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'is_bad_bot': true,
|
||||||
|
'device_id': 'dev',
|
||||||
|
});
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when not flagged', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'is_bad_bot': 'false',
|
||||||
|
'device_id': 'dev',
|
||||||
|
});
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal file
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/playback_error_recovery.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldAutoRecoverFromPlayerError', () {
|
||||||
|
test('recovers Source error / generic failures under the refresh cap', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(refreshCapReached: false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(
|
||||||
|
refreshCapReached: false,
|
||||||
|
errorCode: null,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops once the refresh cap is reached', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(refreshCapReached: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defers mediaServicesWereReset to its dedicated rebuild path', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(
|
||||||
|
refreshCapReached: false,
|
||||||
|
errorCode: 'mediaServicesWereReset',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('playerErrorRetryDelay', () {
|
||||||
|
test('refreshes 4xx token failures immediately', () {
|
||||||
|
expect(playerErrorRetryDelay(statusCode: 403), Duration.zero);
|
||||||
|
expect(playerErrorRetryDelay(statusCode: 404), Duration.zero);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waits briefly for network / Source errors without status', () {
|
||||||
|
expect(
|
||||||
|
playerErrorRetryDelay(),
|
||||||
|
VideoTimingConstants.initRetryDelay,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
playerErrorRetryDelay(statusCode: 500),
|
||||||
|
VideoTimingConstants.initRetryDelay,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import 'package:flutter_test/flutter_test.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/stream.dart';
|
||||||
|
import 'package:frosty/models/user.dart';
|
||||||
|
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||||
|
|
||||||
|
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(StreamViewerSort.viewersDesc);
|
||||||
|
registerFallbackValue(<String>[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
late MockTwitchApi api;
|
||||||
|
late MockTwitchGqlApi gqlApi;
|
||||||
|
late AuthStore authStore;
|
||||||
|
late SettingsStore settingsStore;
|
||||||
|
|
||||||
|
StreamTwitch stream(String id, int viewers) => StreamTwitch(
|
||||||
|
id,
|
||||||
|
'user$id',
|
||||||
|
'User$id',
|
||||||
|
'game',
|
||||||
|
'Game',
|
||||||
|
'title',
|
||||||
|
viewers,
|
||||||
|
'2026-01-01T00:00:00Z',
|
||||||
|
'https://cdn/live_user_$id-{width}x{height}.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> waitUntilIdle(ListStore store) async {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
for (var i = 0; i < 50 && store.isLoading; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
api = MockTwitchApi();
|
||||||
|
gqlApi = MockTwitchGqlApi();
|
||||||
|
authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi);
|
||||||
|
settingsStore = SettingsStore()..streamLanguages = [];
|
||||||
|
|
||||||
|
when(() => api.getUserInfo()).thenAnswer(
|
||||||
|
(_) async => const UserTwitch(
|
||||||
|
'123',
|
||||||
|
'me',
|
||||||
|
'Me',
|
||||||
|
'https://cdn/me.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||||
|
(_) async => <UserBlockedTwitch>[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ListStore tag GQL pagination', () {
|
||||||
|
test('keeps server ASC order across GQL pages', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.getStreams(
|
||||||
|
freeformTags: any(named: 'freeformTags'),
|
||||||
|
languages: any(named: 'languages'),
|
||||||
|
sort: any(named: 'sort'),
|
||||||
|
cursor: any(named: 'cursor'),
|
||||||
|
first: any(named: 'first'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((invocation) async {
|
||||||
|
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||||
|
if (cursor == null) {
|
||||||
|
return StreamsTwitch(
|
||||||
|
[stream('1', 3), stream('2', 7)],
|
||||||
|
const {'cursor': 'c1'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(cursor, 'c1');
|
||||||
|
return StreamsTwitch(
|
||||||
|
[stream('3', 12), stream('4', 20)],
|
||||||
|
const {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsStore.streamViewerSort = StreamViewerSort.viewersAsc;
|
||||||
|
|
||||||
|
final store = ListStore(
|
||||||
|
authStore: authStore,
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
twitchApi: api,
|
||||||
|
twitchGqlApi: gqlApi,
|
||||||
|
listType: ListType.tag,
|
||||||
|
freeformTag: 'Deutsch',
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilIdle(store);
|
||||||
|
|
||||||
|
expect(store.streams.map((s) => s.userId), ['1', '2']);
|
||||||
|
expect(store.hasMore, isTrue);
|
||||||
|
|
||||||
|
await store.getStreams();
|
||||||
|
await waitUntilIdle(store);
|
||||||
|
|
||||||
|
expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']);
|
||||||
|
expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]);
|
||||||
|
expect(store.hasMore, isFalse);
|
||||||
|
verifyNever(
|
||||||
|
() => api.getDirectoryStreams(
|
||||||
|
languages: any(named: 'languages'),
|
||||||
|
gameId: any(named: 'gameId'),
|
||||||
|
freeformTag: any(named: 'freeformTag'),
|
||||||
|
cursor: any(named: 'cursor'),
|
||||||
|
minCount: any(named: 'minCount'),
|
||||||
|
maxPages: any(named: 'maxPages'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to Helix when GQL page-2 integrity fails', () async {
|
||||||
|
var gqlCalls = 0;
|
||||||
|
when(
|
||||||
|
() => gqlApi.getStreams(
|
||||||
|
freeformTags: any(named: 'freeformTags'),
|
||||||
|
languages: any(named: 'languages'),
|
||||||
|
sort: any(named: 'sort'),
|
||||||
|
cursor: any(named: 'cursor'),
|
||||||
|
first: any(named: 'first'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((invocation) async {
|
||||||
|
gqlCalls++;
|
||||||
|
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||||
|
if (cursor == null) {
|
||||||
|
return StreamsTwitch(
|
||||||
|
[stream('1', 100)],
|
||||||
|
const {'cursor': 'c1'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const IntegrityCheckException('failed integrity check');
|
||||||
|
});
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => api.getDirectoryStreams(
|
||||||
|
languages: any(named: 'languages'),
|
||||||
|
gameId: any(named: 'gameId'),
|
||||||
|
freeformTag: any(named: 'freeformTag'),
|
||||||
|
cursor: any(named: 'cursor'),
|
||||||
|
minCount: any(named: 'minCount'),
|
||||||
|
maxPages: any(named: 'maxPages'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => StreamsTwitch(
|
||||||
|
[stream('1', 100), stream('9', 50)],
|
||||||
|
const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final store = ListStore(
|
||||||
|
authStore: authStore,
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
twitchApi: api,
|
||||||
|
twitchGqlApi: gqlApi,
|
||||||
|
listType: ListType.tag,
|
||||||
|
freeformTag: 'Deutsch',
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilIdle(store);
|
||||||
|
expect(store.streams.map((s) => s.userId), ['1']);
|
||||||
|
|
||||||
|
await store.getStreams();
|
||||||
|
await waitUntilIdle(store);
|
||||||
|
|
||||||
|
expect(gqlCalls, greaterThanOrEqualTo(2));
|
||||||
|
expect(store.streams.map((s) => s.userId), ['1', '9']);
|
||||||
|
verify(
|
||||||
|
() => api.getDirectoryStreams(
|
||||||
|
languages: any(named: 'languages'),
|
||||||
|
gameId: any(named: 'gameId'),
|
||||||
|
freeformTag: any(named: 'freeformTag'),
|
||||||
|
cursor: any(named: 'cursor'),
|
||||||
|
minCount: any(named: 'minCount'),
|
||||||
|
maxPages: any(named: 'maxPages'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.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/apis/twitch_integrity.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MockTwitchApi extends Mock implements TwitchApi {}
|
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||||
|
|
||||||
|
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MockTwitchApi api;
|
late MockTwitchApi api;
|
||||||
|
late MockTwitchGqlApi gqlApi;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
api = MockTwitchApi();
|
api = MockTwitchApi();
|
||||||
|
gqlApi = MockTwitchGqlApi();
|
||||||
when(() => api.getUserInfo()).thenAnswer(
|
when(() => api.getUserInfo()).thenAnswer(
|
||||||
(_) async => const UserTwitch(
|
(_) async => const UserTwitch(
|
||||||
'12345',
|
'12345',
|
||||||
|
|
@ -27,42 +38,39 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
UserStore makeStore({required bool hasModeratorScopes}) => UserStore(
|
||||||
|
twitchApi: api,
|
||||||
|
twitchGqlApi: gqlApi,
|
||||||
|
hasModeratorScopes: () => hasModeratorScopes,
|
||||||
|
);
|
||||||
|
|
||||||
group('UserStore moderated channels gating', () {
|
group('UserStore moderated channels gating', () {
|
||||||
test('init skips moderated-channels without moderator scopes', () async {
|
test('init skips moderated-channels without moderator scopes', () async {
|
||||||
final store = UserStore(
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
twitchApi: api,
|
|
||||||
hasModeratorScopes: () => false,
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.init();
|
await userStore.init();
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
||||||
expect(store.moderatedChannels, isEmpty);
|
expect(userStore.moderatedChannels, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('init fetches moderated-channels when moderator scopes exist', () async {
|
test('init fetches moderated-channels when moderator scopes exist', () async {
|
||||||
final store = UserStore(
|
final userStore = makeStore(hasModeratorScopes: true);
|
||||||
twitchApi: api,
|
|
||||||
hasModeratorScopes: () => true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.init();
|
await userStore.init();
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
verify(() => api.getModeratedChannels(id: '12345')).called(1);
|
verify(() => api.getModeratedChannels(id: '12345')).called(1);
|
||||||
expect(store.moderatedChannels, ['999']);
|
expect(userStore.moderatedChannels, ['999']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refreshModeratedChannels no-ops without moderator scopes', () async {
|
test('refreshModeratedChannels no-ops without moderator scopes', () async {
|
||||||
final store = UserStore(
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
twitchApi: api,
|
await userStore.init();
|
||||||
hasModeratorScopes: () => false,
|
|
||||||
);
|
|
||||||
await store.init();
|
|
||||||
clearInteractions(api);
|
clearInteractions(api);
|
||||||
|
|
||||||
await store.refreshModeratedChannels();
|
await userStore.refreshModeratedChannels();
|
||||||
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,13 +83,10 @@ void main() {
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async => true);
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
final store = UserStore(
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
twitchApi: api,
|
await userStore.init();
|
||||||
hasModeratorScopes: () => false,
|
|
||||||
);
|
|
||||||
await store.init();
|
|
||||||
|
|
||||||
final result = await store.deleteMessage(
|
final result = await userStore.deleteMessage(
|
||||||
broadcasterId: '12345',
|
broadcasterId: '12345',
|
||||||
messageId: 'msg',
|
messageId: 'msg',
|
||||||
);
|
);
|
||||||
|
|
@ -96,4 +101,168 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('UserStore follow', () {
|
||||||
|
test('isFollowingChannel caches Helix result', () async {
|
||||||
|
when(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: '12345',
|
||||||
|
broadcasterId: '999',
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
await userStore.init();
|
||||||
|
|
||||||
|
expect(await userStore.isFollowingChannel('999'), isTrue);
|
||||||
|
expect(await userStore.isFollowingChannel('999'), isTrue);
|
||||||
|
verify(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: '12345',
|
||||||
|
broadcasterId: '999',
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('follow retries after integrity failure', () async {
|
||||||
|
var calls = 0;
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) {
|
||||||
|
throw const IntegrityCheckException('failed integrity check');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
when(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => (
|
||||||
|
token: 'integrity',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
await userStore.init();
|
||||||
|
await userStore.follow(targetId: '999', gqlToken: 'web-token');
|
||||||
|
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isTrue);
|
||||||
|
expect(calls, 2);
|
||||||
|
verify(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: 'web-token',
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unfollow updates cache', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.unfollowUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
await userStore.init();
|
||||||
|
await userStore.unfollow(targetId: '999', gqlToken: 'web-token');
|
||||||
|
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runWithGqlIntegrity refreshes token and retries', () async {
|
||||||
|
var calls = 0;
|
||||||
|
when(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => (
|
||||||
|
token: 'fresh-integrity',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
final result = await userStore.runWithGqlIntegrity<String>(
|
||||||
|
gqlToken: 'web-token',
|
||||||
|
action: ({authToken, integrityToken, deviceId}) async {
|
||||||
|
calls++;
|
||||||
|
expect(authToken, 'web-token');
|
||||||
|
expect(deviceId, isNotEmpty);
|
||||||
|
if (calls == 1) {
|
||||||
|
throw const IntegrityCheckException('failed integrity check');
|
||||||
|
}
|
||||||
|
expect(integrityToken, 'fresh-integrity');
|
||||||
|
return 'ok';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, 'ok');
|
||||||
|
expect(calls, 2);
|
||||||
|
verify(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: 'web-token',
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runWithGqlIntegrity rethrows when no gqlToken', () async {
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
userStore.runWithGqlIntegrity<void>(
|
||||||
|
gqlToken: null,
|
||||||
|
action: ({authToken, integrityToken, deviceId}) async {
|
||||||
|
throw const IntegrityCheckException('failed integrity check');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
verifyNever(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyWebIntegrity stores device id for later GQL calls', () async {
|
||||||
|
final userStore = makeStore(hasModeratorScopes: false);
|
||||||
|
await userStore.applyWebIntegrity(
|
||||||
|
GqlIntegrityCredentials(
|
||||||
|
integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||||
|
'is_bad_bot': 'false',
|
||||||
|
'device_id': 'webview-device',
|
||||||
|
})))}.sig',
|
||||||
|
deviceId: 'webview-device',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final deviceId = await userStore.runWithGqlIntegrity<String>(
|
||||||
|
gqlToken: 'web-token',
|
||||||
|
action: ({authToken, integrityToken, deviceId}) async {
|
||||||
|
expect(deviceId, 'webview-device');
|
||||||
|
expect(integrityToken, isNotNull);
|
||||||
|
return deviceId!;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(deviceId, 'webview-device');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
254
test/widgets/follow_channel_controller_test.dart
Normal file
254
test/widgets/follow_channel_controller_test.dart
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.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/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/models/user.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MockAuthStore extends Mock implements AuthStore {}
|
||||||
|
|
||||||
|
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||||
|
|
||||||
|
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockAuthStore authStore;
|
||||||
|
late MockTwitchApi api;
|
||||||
|
late MockTwitchGqlApi gqlApi;
|
||||||
|
late UserStore userStore;
|
||||||
|
var notifyCount = 0;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
authStore = MockAuthStore();
|
||||||
|
api = MockTwitchApi();
|
||||||
|
gqlApi = MockTwitchGqlApi();
|
||||||
|
notifyCount = 0;
|
||||||
|
|
||||||
|
when(() => api.getUserInfo()).thenAnswer(
|
||||||
|
(_) async => const UserTwitch(
|
||||||
|
'12345',
|
||||||
|
'testuser',
|
||||||
|
'TestUser',
|
||||||
|
'https://cdn/profile.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||||
|
(_) async => <UserBlockedTwitch>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
userStore = UserStore(
|
||||||
|
twitchApi: api,
|
||||||
|
twitchGqlApi: gqlApi,
|
||||||
|
hasModeratorScopes: () => false,
|
||||||
|
);
|
||||||
|
await userStore.init();
|
||||||
|
|
||||||
|
when(() => authStore.user).thenReturn(userStore);
|
||||||
|
when(() => authStore.isLoggedIn).thenReturn(true);
|
||||||
|
when(() => authStore.gqlToken).thenReturn('web-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
FollowChannelController makeController({
|
||||||
|
String userId = '999',
|
||||||
|
bool? initialIsFollowing,
|
||||||
|
}) {
|
||||||
|
return FollowChannelController(
|
||||||
|
authStore: authStore,
|
||||||
|
userId: userId,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onChanged: () => notifyCount++,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('FollowChannelController', () {
|
||||||
|
test('hides when logged out', () {
|
||||||
|
when(() => authStore.isLoggedIn).thenReturn(false);
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.canFollow, isFalse);
|
||||||
|
expect(controller.shouldShow, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides for own channel', () {
|
||||||
|
final controller = makeController(userId: '12345');
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.isSelf, isTrue);
|
||||||
|
expect(controller.canFollow, isFalse);
|
||||||
|
expect(controller.shouldShow, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses initialIsFollowing without Helix lookup', () {
|
||||||
|
final controller = makeController(initialIsFollowing: true);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
expect(controller.shouldShow, isTrue);
|
||||||
|
verifyNever(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: any(named: 'userId'),
|
||||||
|
broadcasterId: any(named: 'broadcasterId'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads follow state from Helix and caches', () async {
|
||||||
|
when(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: '12345',
|
||||||
|
broadcasterId: '999',
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
|
final controller = makeController();
|
||||||
|
controller.init();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
expect(controller.loading, isFalse);
|
||||||
|
expect(controller.shouldShow, isTrue);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle follows then unfollows', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => gqlApi.unfollowUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(await controller.toggle(), isTrue);
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isTrue);
|
||||||
|
|
||||||
|
expect(await controller.toggle(), isFalse);
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle throws when web session missing', () async {
|
||||||
|
when(() => authStore.gqlToken).thenReturn(null);
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.toggle(),
|
||||||
|
throwsA(isA<FollowNeedsWebSessionException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle rethrows integrity and clears action flag', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenThrow(const IntegrityCheckException('failed integrity check'));
|
||||||
|
when(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenThrow(const IntegrityCheckException('bad bot'));
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.toggle(),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
expect(controller.actionInProgress, isFalse);
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryAfterIntegrity applies credentials and follows', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
final okToken =
|
||||||
|
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||||
|
'is_bad_bot': 'false',
|
||||||
|
'device_id': 'device-abc',
|
||||||
|
})))}.sig';
|
||||||
|
|
||||||
|
final nowFollowing = await controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: false,
|
||||||
|
credentials: GqlIntegrityCredentials(
|
||||||
|
integrityToken: okToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nowFollowing, isTrue);
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
verify(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: '999',
|
||||||
|
authToken: 'web-token',
|
||||||
|
integrityToken: okToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryAfterIntegrity rejects bad_bot integrity', () async {
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
final badToken =
|
||||||
|
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'device_id': 'device-abc',
|
||||||
|
})))}.sig';
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: false,
|
||||||
|
credentials: GqlIntegrityCredentials(
|
||||||
|
integrityToken: badToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
expect(controller.actionInProgress, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue