Compare commits

..

10 commits
v5.2.5 ... main

Author SHA1 Message Date
378c230815 release v5.3.1
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
eb39a4ac00 Auto-recover native player after Source errors.
Screen-off and network switches often leave ExoPlayer dead without a 4xx status; refresh automatically (and on app resume) instead of waiting for Reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
59290b23b1 release v5.3.0
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 9m42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:52 +02:00
9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00
9ced2fe212 release v5.2.9
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 07:32:16 +02:00
3fb393166f Fix tag directory sorting and follow Client-Integrity.
Paginate tag (and ASC) lists via GQL with integrity retry; mint follow
integrity from a WebView because Dio /integrity tokens are bad_bot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 07:32:16 +02:00
383e786479 release v5.2.8
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m45s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:09 +02:00
30848e6fe8 Add follow and unfollow for live and offline channels.
Long-press actions use Helix for follow status and web GQL mutations via the linked session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:05 +02:00
318dc84dc5 Fix tag infinite scroll after GQL ASC first page.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m37s
GQL page-2 fails integrity, so fewest starts with GQL then continues via Helix tag filter with dedupe; empty Helix windows no longer end the list while a cursor remains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 12:23:15 +02:00
5a787e0134 Fix fewest-viewers sort via GQL VIEWER_COUNT_ASC.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m16s
Helix directories are viewer-desc only; local reverse of top pages wrongly showed high-viewer streams for ascending sort.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 12:03:12 +02:00
32 changed files with 2787 additions and 225 deletions

View file

@ -50,8 +50,11 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable) - Viewer sort is persisted (`streamViewerSort`)
- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity - **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

View file

@ -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)

View file

@ -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;

View file

@ -229,6 +229,25 @@ 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,
@ -252,8 +271,12 @@ class TwitchApi extends BaseApiClient {
/// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// optional [freeformTag] (case-insensitive tag name), or the directory ends.
/// ///
/// Helix is always viewer-desc and supports at most one `language` query /// Helix is always viewer-desc and supports at most one `language` query
/// param; tags are filtered client-side. Prefer this over GQL for pagination /// param; tags are filtered client-side. Used as fallback when GQL directory
/// (GQL page-2 requires a non-bot Client-Integrity token). /// 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({ Future<StreamsTwitch> getDirectoryStreams({
List<String> languages = const [], List<String> languages = const [],
String? gameId, String? gameId,
@ -293,6 +316,10 @@ class TwitchApi extends BaseApiClient {
); );
} }
// 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 // When filtering by tag and exactly one language, still pass language to
// Helix so each page is denser in matches. // Helix so each page is denser in matches.
final helixLanguage = final helixLanguage =
@ -303,7 +330,7 @@ class TwitchApi extends BaseApiClient {
String? nextCursor; String? nextCursor;
var pages = 0; var pages = 0;
while (matched.length < minCount && pages < maxPages) { while (matched.length < effectiveMin && pages < effectiveMax) {
final page = gameId == null final page = gameId == null
? await getTopStreams(cursor: pageCursor, language: helixLanguage) ? await getTopStreams(cursor: pageCursor, language: helixLanguage)
: await getStreamsUnderCategory( : await getStreamsUnderCategory(
@ -316,7 +343,9 @@ class TwitchApi extends BaseApiClient {
for (final stream in page.data) { for (final stream in page.data) {
if (matches(stream)) matched.add(stream); if (matches(stream)) matched.add(stream);
} }
if (nextCursor == null || page.data.isEmpty) { if (nextCursor == null ||
nextCursor.isEmpty ||
page.data.isEmpty) {
nextCursor = null; nextCursor = null;
break; break;
} }
@ -325,7 +354,9 @@ class TwitchApi extends BaseApiClient {
return StreamsTwitch( return StreamsTwitch(
matched, matched,
nextCursor != null ? {'cursor': nextCursor} : const {}, nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
); );
} }

View file

@ -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,27 +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) { if (errors is List && errors.isNotEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
if (message.toLowerCase().contains('integrity')) {
throw IntegrityCheckException(message);
}
final data = response['data']; final data = response['data'];
final dataEmpty = data == null || final dataEmpty = data == null ||
(data is Map && (data is Map &&
data.values.every((value) => value == null)); data.values.every((value) => value == null));
if (dataEmpty) { if (dataEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
throw ApiException(message); 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
@ -213,15 +236,18 @@ class TwitchGqlApi extends BaseApiClient {
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
/// ///
/// Twitch caps `first` at 30 for this connection. Cursor pagination often /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
/// fails with `IntegrityCheckFailed` without a web integrity token — callers /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
/// should treat a failed page-2 as end-of-list. /// them page-2 throws [IntegrityCheckException].
Future<StreamsTwitch> getStreams({ Future<StreamsTwitch> getStreams({
List<String>? freeformTags, List<String>? freeformTags,
List<String>? languages, 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 cappedFirst = first.clamp(1, 30); final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty final gqlLangs = languages == null || languages.isEmpty
@ -231,24 +257,31 @@ class TwitchGqlApi extends BaseApiClient {
.where((l) => l.isNotEmpty) .where((l) => l.isNotEmpty)
.toList(); .toList();
final response = await _gql({ final response = await _gql(
'operationName': 'FrostyTopStreams', {
'query': 'operationName': 'FrostyTopStreams',
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' 'query':
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
r' edges { cursor node {' r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
r' id title viewersCount createdAt language' r' edges { cursor node {'
r' game { id name }' r' id title viewersCount createdAt language'
r' broadcaster { id login displayName }' r' game { id name }'
r' } } pageInfo { hasNextPage } } }', r' broadcaster { id login displayName }'
'variables': { r' } } pageInfo { hasNextPage } } }',
'first': cappedFirst, 'variables': {
'after': cursor, 'first': cappedFirst,
'tags': freeformTags, 'after': cursor,
'sort': sort.gqlValue, 'tags': freeformTags,
'langs': gqlLangs, '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 {};
@ -262,6 +295,9 @@ class TwitchGqlApi extends BaseApiClient {
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 cappedFirst = first.clamp(1, 30); final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty final gqlLangs = languages == null || languages.isEmpty
@ -271,24 +307,31 @@ class TwitchGqlApi extends BaseApiClient {
.where((l) => l.isNotEmpty) .where((l) => l.isNotEmpty)
.toList(); .toList();
final response = await _gql({ final response = await _gql(
'operationName': 'FrostyGameStreams', {
'query': 'operationName': 'FrostyGameStreams',
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' 'query':
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
r' edges { cursor node {' r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
r' id title viewersCount createdAt language' r' edges { cursor node {'
r' game { id name }' r' id title viewersCount createdAt language'
r' broadcaster { id login displayName }' r' game { id name }'
r' } } pageInfo { hasNextPage } } } }', r' broadcaster { id login displayName }'
'variables': { r' } } pageInfo { hasNextPage } } } }',
'id': gameId, 'variables': {
'first': cappedFirst, 'id': gameId,
'after': cursor, 'first': cappedFirst,
'sort': sort.gqlValue, 'after': cursor,
'langs': gqlLangs, '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) {
@ -297,6 +340,176 @@ 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?;

View 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,
});
}

View file

@ -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));

View file

@ -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),
], ],

View 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,
),
],
);
},
),
),
);
}
}

View file

@ -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';
@ -683,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;
} }
@ -1310,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

View 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;
}

View file

@ -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,
), ),
); );
}, },

View file

@ -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,
), ),
); );
}, },

View file

@ -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,
), ),
); );
}, },

View file

@ -51,6 +51,14 @@ abstract class ListStoreBase with Store {
/// 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;
@ -59,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 =>
@ -250,6 +259,14 @@ abstract class ListStoreBase with Store {
} }
} }
/// 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 {
@ -260,90 +277,115 @@ abstract class ListStoreBase with Store {
try { try {
final languages = _languageFilter; final languages = _languageFilter;
final isAsc = sortOrder == StreamViewerSort.viewersAsc; final isAsc = sortOrder == StreamViewerSort.viewersAsc;
// Asc needs a deeper Helix scan so client-side re-sort can surface final useGql = _preferGqlDirectory;
// lower-viewer streams (Helix itself is always viewer-desc). _lastPageWasGql = false;
final minCount = isAsc ? 80 : 50;
final maxPages = isAsc
? (_streamsCursor == null ? 20 : 10)
: (_streamsCursor == null ? 12 : 8);
final StreamsTwitch newStreams; StreamsTwitch? newStreams;
switch (listType) { if (listType == ListType.followed) {
case ListType.followed: newStreams = await twitchApi.getFollowedStreams(
newStreams = await twitchApi.getFollowedStreams( id: authStore.user.details!.id,
id: authStore.user.details!.id, cursor: _streamsCursor,
cursor: _streamsCursor, );
); } else if (useGql) {
break; newStreams = await _fetchGqlDirectory(languages: languages);
case ListType.top: if (newStreams != null) {
newStreams = await twitchApi.getDirectoryStreams( _lastPageWasGql = true;
languages: languages, }
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
case ListType.category:
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
gameId: categoryId!,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
case ListType.tag:
// Helix + client tag filter (GQL freeformTags can't paginate without
// a non-bot Client-Integrity token).
newStreams = await twitchApi.getDirectoryStreams(
languages: languages,
freeformTag: freeformTag,
cursor: _streamsCursor,
minCount: minCount,
maxPages: maxPages,
);
break;
} }
// Empty continuation ⇒ end of directory. // Helix: Top/category DESC, or GQL unavailable / integrity fallback.
final isContinuation = _streamsCursor != null; newStreams ??= await twitchApi.getDirectoryStreams(
if (isContinuation && newStreams.data.isEmpty) { languages: languages,
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; _streamsCursor = null;
_helixFill = false;
_error = null; _error = null;
return; return;
} }
if (_streamsCursor == null) { // GQL pages are already server-sorted — keep API order.
_allStreams = newStreams.data.asObservable(); // 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 { } else {
final existingIds = _allStreams.map((s) => s.userId).toSet(); final existingIds = _allStreams.map((s) => s.userId).toSet();
_allStreams.addAll( _allStreams.addAll(
newStreams.data.where((s) => !existingIds.contains(s.userId)), pageData.where((s) => !existingIds.contains(s.userId)),
); );
} }
final next = newStreams.pagination['cursor'];
_streamsCursor = (next != null && next.isNotEmpty) ? next : null;
_applyViewerSort(); 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;
}
_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) { if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null; _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 (_streamsCursor != null && _allStreams.isNotEmpty) { 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; _streamsCursor = null;
if (_allStreams.isNotEmpty) _error = 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) { if (_streamsCursor != null && _allStreams.isNotEmpty) {
_streamsCursor = null; _streamsCursor = null;
_helixFill = false;
_error = null; _error = null;
} }
} finally { } finally {
@ -351,15 +393,67 @@ abstract class ListStoreBase with Store {
} }
} }
void _applyViewerSort() { /// GQL directory page with integrity retry. Returns null to trigger Helix.
final sorted = _allStreams.toList(); Future<StreamsTwitch?> _fetchGqlDirectory({
sorted.sort((a, b) { required List<String> languages,
final cmp = a.viewerCount.compareTo(b.viewerCount); }) async {
return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp; final gql = twitchGqlApi;
}); if (gql == null) return null;
_allStreams = sorted.asObservable();
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. /// Persists viewer sort and reloads via the settings reaction.
@action @action
Future<void> setSortOrder(StreamViewerSort order) async { Future<void> setSortOrder(StreamViewerSort order) async {
@ -441,6 +535,8 @@ abstract class ListStoreBase with Store {
} }
_streamsCursor = null; _streamsCursor = null;
_helixFill = false;
_lastPageWasGql = false;
await getStreams(); await getStreams();
} }
@ -486,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 }

View file

@ -260,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,
@ -320,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),
@ -331,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,
); );
}, },
); );
@ -373,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(
@ -384,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,
), ),
); );
}, },
@ -432,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,
), ),
); );
}, },

View file

@ -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,
); );
} }

View file

@ -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;
} }

View file

@ -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,

View 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'),
],
),
);
}
}

View 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();
}

View 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'),
),
],
);
}
}

View file

@ -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}'),
), ),
], ],
); );

View file

@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 5.2.5+75 version: 5.3.1+81
environment: environment:
sdk: ^3.9.0 sdk: ^3.9.0

View file

@ -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,
);
});
});
} }

View 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>()),
);
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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,
);
});
});
}

View 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();
});
});
}

View file

@ -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');
});
});
} }

View 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);
});
});
}