diff --git a/DESIGN.md b/DESIGN.md index 07a0fe02..882b4114 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,26 +36,6 @@ Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player - Third-party proxies - HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) -## Tags + viewer sort (v5.2.2+) - -Helix cannot filter by freeform tags or sort ascending. GQL covers both: - -- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`) -- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton` - -Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. - -## Stream languages (v5.2.4+) - -Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. - -- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter -- Viewer sort is persisted (`streamViewerSort`) -- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session. -- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`. -- **Top / category most viewers (desc):** Helix directory order (paginates) -- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback - ## Build Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/assets/release-notes.md b/assets/release-notes.md index 6d112234..26387850 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,28 +1,77 @@ -## v5.3.1 +## v5.2.1 -October 12, 2026 - -### Bug Fixes - -- 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. - ---- - -## v5.3.0 - -October 12, 2026 +September 30, 2026 ### New Features -- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. +- 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.9 +## v5.2.0 -October 7, 2026 +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 -- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. -- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. +- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream. +- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub). + +--- + +## v5.1.0 + +June 13, 2026 + +### 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 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 + +January 9, 2026 + +### Bug Fixes + +- Fixed lag/frame drops in overlay chat mode. +- Fixed draggable divider still interactable in fullscreen mode. +- 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) diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index 87163d84..4fd3745c 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -44,13 +44,6 @@ class UnauthorizedException extends ApiException { : 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 abstract class BaseApiClient { final Dio _dio; diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index f3cc787d..148b7efa 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -175,23 +175,11 @@ class TwitchApi extends BaseApiClient { } } - /// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests. - /// - /// [language] is an optional ISO 639-1 code (Helix accepts only one). - Future getTopStreams({ - String? cursor, - String? language, - int first = 100, - }) async { - final queryParams = { - 'first': first, - if (cursor != null) 'after': cursor, - if (language != null && language.isNotEmpty) 'language': language, - }; - + /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. + Future getTopStreams({String? cursor}) async { final data = await get( '/streams', - queryParameters: queryParams, + queryParameters: cursor != null ? {'after': cursor} : null, ); return StreamsTwitch.fromJson(data); @@ -202,7 +190,7 @@ class TwitchApi extends BaseApiClient { required String id, String? cursor, }) async { - final queryParams = {'user_id': id, 'first': '100'}; + final queryParams = {'user_id': id}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -218,7 +206,7 @@ class TwitchApi extends BaseApiClient { required String userId, String? cursor, }) async { - final queryParams = {'user_id': userId, 'first': '100'}; + final queryParams = {'user_id': userId, 'first': '20'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -229,153 +217,19 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } - /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete - /// follows were removed from the public API). - Future isFollowingChannel({ - required String userId, - required String broadcasterId, - }) async { - final data = await get( - '/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. Future getStreamsUnderCategory({ required String gameId, String? cursor, - String? language, - int first = 100, }) async { - final queryParams = { - 'game_id': gameId, - 'first': first, - if (cursor != null) 'after': cursor, - if (language != null && language.isNotEmpty) 'language': language, - }; + final queryParams = {'game_id': gameId}; + if (cursor != null) queryParams['after'] = cursor; final data = await get('/streams', queryParameters: queryParams); return StreamsTwitch.fromJson(data); } - /// Fetches Helix directory pages until enough streams match [languages] and - /// optional [freeformTag] (case-insensitive tag name), or the directory ends. - /// - /// Helix is always viewer-desc and supports at most one `language` query - /// param; tags are filtered client-side. Used as fallback when GQL directory - /// pagination lacks a Client-Integrity token. - /// - /// When a [freeformTag] filter yields no matches in a Helix window, this - /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so - /// callers can continue — an empty match set alone is not end-of-list. - Future getDirectoryStreams({ - List languages = const [], - String? gameId, - String? freeformTag, - String? cursor, - int minCount = 50, - int maxPages = 12, - }) async { - final wantedLangs = languages - .map((l) => l.trim().toLowerCase()) - .where((l) => l.isNotEmpty) - .toSet(); - final tagNeedle = freeformTag?.trim().toLowerCase(); - final hasTag = tagNeedle != null && tagNeedle.isNotEmpty; - - bool matches(StreamTwitch stream) { - if (wantedLangs.isNotEmpty && - !wantedLangs.contains(stream.language.toLowerCase())) { - return false; - } - if (hasTag) { - final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle); - if (!hit) return false; - } - return true; - } - - // Fast path: no tag filter and 0–1 languages → one Helix call. - if (!hasTag && wantedLangs.length <= 1) { - final lang = wantedLangs.isEmpty ? null : wantedLangs.first; - return gameId == null - ? getTopStreams(cursor: cursor, language: lang) - : getStreamsUnderCategory( - gameId: gameId, - cursor: cursor, - language: lang, - ); - } - - // Sparse tags need a deeper Helix scan per request. - final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount; - final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages; - - // When filtering by tag and exactly one language, still pass language to - // Helix so each page is denser in matches. - final helixLanguage = - wantedLangs.length == 1 ? wantedLangs.first : null; - - final matched = []; - String? pageCursor = cursor; - String? nextCursor; - var pages = 0; - - while (matched.length < effectiveMin && pages < effectiveMax) { - final page = gameId == null - ? await getTopStreams(cursor: pageCursor, language: helixLanguage) - : await getStreamsUnderCategory( - gameId: gameId, - cursor: pageCursor, - language: helixLanguage, - ); - pages++; - nextCursor = page.pagination['cursor']; - for (final stream in page.data) { - if (matches(stream)) matched.add(stream); - } - if (nextCursor == null || - nextCursor.isEmpty || - page.data.isEmpty) { - nextCursor = null; - break; - } - pageCursor = nextCursor; - } - - return StreamsTwitch( - matched, - nextCursor != null && nextCursor.isNotEmpty - ? {'cursor': nextCursor} - : const {}, - ); - } - - /// Deprecated name — use [getDirectoryStreams]. - Future getStreamsMatchingLanguages({ - required List languages, - String? gameId, - String? cursor, - int minCount = 40, - int maxPages = 8, - }) => - getDirectoryStreams( - languages: languages, - gameId: gameId, - cursor: cursor, - minCount: minCount, - maxPages: maxPages, - ); - /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({required String userLogin}) async { final data = await get( diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 3923d500..f72e7250 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,78 +3,16 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; -import 'package:frosty/models/stream.dart'; -/// Viewer sort for GQL directory / tag stream lists. -enum StreamViewerSort { - viewersDesc, - viewersAsc; - - String get gqlValue => switch (this) { - StreamViewerSort.viewersDesc => 'VIEWER_COUNT', - StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC', - }; -} - -/// Twitch GQL API client for playback tokens and directory queries -/// (freeform tags + sorted streams) that Helix does not expose. +/// Twitch GQL API client for fetching playback access tokens. /// /// Uses the Twitch web client ID (not the app's registered client ID) /// since the GQL endpoint requires it. class TwitchGqlApi extends BaseApiClient { TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); - static const _gqlHeaders = { - 'Client-ID': twitchGqlClientId, - 'Content-Type': 'application/json', - }; - - Future _gql( - Map body, { - Map? extraHeaders, - }) async { - final response = await post( - '/gql', - data: body, - headers: {..._gqlHeaders, ...?extraHeaders}, - ); - final errors = response['errors']; - 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 dataEmpty = data == null || - (data is Map && - data.values.every((value) => value == null)); - if (dataEmpty) { - throw ApiException(message); - } - } - return response; - } - - /// Optional web-session headers for directory pagination (page-2+). - Map _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 /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -202,358 +140,4 @@ class TwitchGqlApi extends BaseApiClient { } return buffer.toString(); } - - /// Freeform tag name suggestions for search-as-you-type. - Future> searchFreeformTags({ - required String query, - int first = 20, - }) async { - if (query.trim().isEmpty) return const []; - - final response = await _gql({ - 'operationName': 'SearchFreeformTags', - 'query': - r'query SearchFreeformTags($query: String!, $first: Int) {' - r' searchFreeformTags(userQuery: $query, first: $first) {' - r' edges { node { tagName } } } }', - 'variables': {'query': query, 'first': first}, - }); - - final edges = - (((response['data'] as JsonMap?)?['searchFreeformTags'] - as JsonMap?)?['edges'] - as List?) ?? - const []; - - return edges - .map((edge) { - final node = (edge as JsonMap)['node'] as JsonMap?; - return node?['tagName'] as String?; - }) - .whereType() - .toList(); - } - - /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. - /// - /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a - /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without - /// them page-2 throws [IntegrityCheckException]. - Future getStreams({ - List? freeformTags, - List? languages, - StreamViewerSort sort = StreamViewerSort.viewersDesc, - String? cursor, - int first = 30, - String? authToken, - String? integrityToken, - String? deviceId, - }) async { - final cappedFirst = first.clamp(1, 30); - final gqlLangs = languages == null || languages.isEmpty - ? null - : languages - .map((l) => l.trim().toUpperCase()) - .where((l) => l.isNotEmpty) - .toList(); - - final response = await _gql( - { - 'operationName': 'FrostyTopStreams', - 'query': - r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' - r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } }', - 'variables': { - 'first': cappedFirst, - 'after': cursor, - 'tags': freeformTags, - 'sort': sort.gqlValue, - 'langs': gqlLangs, - }, - }, - extraHeaders: _directoryHeaders( - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ), - ); - - final connection = - (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; - return _streamsFromConnection(connection); - } - - /// Live streams under a category/game, with viewer sort and optional languages. - Future getGameStreams({ - required String gameId, - List? languages, - StreamViewerSort sort = StreamViewerSort.viewersDesc, - String? cursor, - int first = 30, - String? authToken, - String? integrityToken, - String? deviceId, - }) async { - final cappedFirst = first.clamp(1, 30); - final gqlLangs = languages == null || languages.isEmpty - ? null - : languages - .map((l) => l.trim().toUpperCase()) - .where((l) => l.isNotEmpty) - .toList(); - - final response = await _gql( - { - 'operationName': 'FrostyGameStreams', - 'query': - r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' - r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } } }', - 'variables': { - 'id': gameId, - 'first': cappedFirst, - 'after': cursor, - 'sort': sort.gqlValue, - 'langs': gqlLangs, - }, - }, - extraHeaders: _directoryHeaders( - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ), - ); - - final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; - if (game == null) { - return const StreamsTwitch([], {}); - } - 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 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 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( - '/integrity', - data: {}, - 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 _mutateFollow({ - required String operationName, - required String sha256Hash, - required String authToken, - required JsonMap variables, - required List successPath, - String? integrityToken, - String? deviceId, - }) async { - final headers = { - '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( - '/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.from(response.first as Map); - } - if (response is Map) { - return Map.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) { - final edges = (connection['edges'] as List?) ?? const []; - final pageInfo = connection['pageInfo'] as JsonMap?; - // Prefer explicit pageInfo; if missing, assume more when we got a full page. - final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30; - - final streams = []; - String? nextCursor; - - for (final edge in edges) { - final edgeMap = edge as JsonMap; - nextCursor = edgeMap['cursor'] as String?; - final node = edgeMap['node'] as JsonMap?; - if (node == null) continue; - streams.add(_streamFromNode(node)); - } - - return StreamsTwitch( - streams, - hasNext && nextCursor != null && nextCursor.isNotEmpty - ? {'cursor': nextCursor} - : const {}, - ); - } - - StreamTwitch _streamFromNode(JsonMap node) { - final broadcaster = node['broadcaster'] as JsonMap? ?? const {}; - final game = node['game'] as JsonMap? ?? const {}; - final login = broadcaster['login'] as String? ?? ''; - final language = (node['language'] as String? ?? '').toLowerCase(); - - return StreamTwitch( - broadcaster['id'] as String? ?? '', - login, - broadcaster['displayName'] as String? ?? login, - game['id'] as String? ?? '', - game['name'] as String? ?? '', - node['title'] as String? ?? '', - (node['viewersCount'] as num?)?.toInt() ?? 0, - node['createdAt'] as String? ?? '', - // Helix-style placeholder so existing thumbnail widgets keep working. - 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', - language, - const [], - ); - } } diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart deleted file mode 100644 index efef0346..00000000 --- a/lib/apis/twitch_integrity.dart +++ /dev/null @@ -1,81 +0,0 @@ -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? 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.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, - }); -} diff --git a/lib/main.dart b/lib/main.dart index b0b87d00..dd221dff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,10 +167,7 @@ void main() async { ); // Create and initialize the authentication store - final authStore = AuthStore( - twitchApi: twitchApiService, - twitchGqlApi: twitchGqlApiService, - ); + final authStore = AuthStore(twitchApi: twitchApiService); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 34a35c60..0f433ff6 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -15,14 +15,6 @@ class StreamTwitch { final String startedAt; final String thumbnailUrl; - /// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown. - @JsonKey(defaultValue: '') - final String language; - - /// Freeform tags from Helix (e.g. `Deutsch`, `English`). - @JsonKey(defaultValue: []) - final List tags; - const StreamTwitch( this.userId, this.userLogin, @@ -32,10 +24,8 @@ class StreamTwitch { this.title, this.viewerCount, this.startedAt, - this.thumbnailUrl, [ - this.language = '', - this.tags = const [], - ]); + this.thumbnailUrl, + ); factory StreamTwitch.fromJson(Map json) => _$StreamTwitchFromJson(json); @@ -48,20 +38,6 @@ class StreamsTwitch { const StreamsTwitch(this.data, this.pagination); - factory StreamsTwitch.fromJson(Map json) { - final rawPagination = json['pagination']; - final pagination = {}; - if (rawPagination is Map) { - final cursor = rawPagination['cursor']; - if (cursor is String && cursor.isNotEmpty) { - pagination['cursor'] = cursor; - } - } - return StreamsTwitch( - (json['data'] as List) - .map((e) => StreamTwitch.fromJson(e as Map)) - .toList(), - pagination, - ); - } + factory StreamsTwitch.fromJson(Map json) => + _$StreamsTwitchFromJson(json); } diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 95f9bff3..fe2fe565 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -16,8 +16,6 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( (json['viewer_count'] as num).toInt(), json['started_at'] as String, json['thumbnail_url'] as String, - json['language'] as String? ?? '', - (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/models/stream_languages.dart b/lib/models/stream_languages.dart deleted file mode 100644 index 455ccc60..00000000 --- a/lib/models/stream_languages.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// ISO 639-1 codes offered in Settings for stream language filtering. -/// -/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form. -class StreamLanguages { - StreamLanguages._(); - - static const all = <({String code, String label})>[ - (code: 'de', label: 'Deutsch'), - (code: 'en', label: 'English'), - (code: 'es', label: 'Español'), - (code: 'fr', label: 'Français'), - (code: 'it', label: 'Italiano'), - (code: 'pt', label: 'Português'), - (code: 'pl', label: 'Polski'), - (code: 'ru', label: 'Русский'), - (code: 'tr', label: 'Türkçe'), - (code: 'nl', label: 'Nederlands'), - (code: 'sv', label: 'Svenska'), - (code: 'no', label: 'Norsk'), - (code: 'da', label: 'Dansk'), - (code: 'fi', label: 'Suomi'), - (code: 'cs', label: 'Čeština'), - (code: 'hu', label: 'Magyar'), - (code: 'ro', label: 'Română'), - (code: 'uk', label: 'Українська'), - (code: 'ja', label: '日本語'), - (code: 'ko', label: '한국어'), - (code: 'zh', label: '中文'), - (code: 'ar', label: 'العربية'), - (code: 'th', label: 'ไทย'), - (code: 'vi', label: 'Tiếng Việt'), - (code: 'id', label: 'Bahasa Indonesia'), - (code: 'hi', label: 'हिन्दी'), - (code: 'other', label: 'Other'), - ]; - - static String labelFor(String code) { - for (final entry in all) { - if (entry.code == code) return entry.label; - } - return code; - } - - /// Helix / settings codes → GQL `Language` enum values. - static List toGqlEnums(Iterable codes) => codes - .map((c) => c.trim().toLowerCase()) - .where((c) => c.isNotEmpty) - .map((c) => c.toUpperCase()) - .toList(); -} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 1013812b..c65c0e4b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,7 +6,6 @@ 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_tabs_store.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_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; @@ -747,14 +746,6 @@ class _VideoChatState extends State aspectRatio: 16 / 9, child: SizedBox(), ), - ChannelInfoHeader( - videoStore: _videoStore, - authStore: context.authStore, - userId: widget.userId, - displayName: widget.userName, - isInSharedChatMode: - _chatStore.isInSharedChatMode, - ), ], Expanded(child: chat), ], diff --git a/lib/screens/channel/video/channel_info_header.dart b/lib/screens/channel/video/channel_info_header.dart deleted file mode 100644 index 0cf77aa2..00000000 --- a/lib/screens/channel/video/channel_info_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -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, - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 31077620..28911fd4 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -15,7 +15,6 @@ import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.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/playback_error_recovery.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/settings/stores/auth_store.dart'; @@ -132,12 +131,6 @@ abstract class NativeVideoStoreBase @readonly var _blockingAds = false; - /// Exposes [_blockingAds] via [VideoPlayerInterface.isBlockingAds] - /// (must live in the hand-written store — build_runner will strip a - /// hand-patched override from the `.g.dart`). - @override - bool get isBlockingAds => _blockingAds; - @readonly var _paused = true; @@ -684,44 +677,26 @@ abstract class NativeVideoStoreBase return; } - // Mid-stream failures (expired HLS session, ExoPlayer "Source error" - // after screen-off / network switch, CDN blips). A fresh token + reload - // usually fixes these — only pin the Reload overlay once retries are - // spent. 4xx refreshes immediately; network/source waits briefly so the - // interface can come back. - if (shouldAutoRecoverFromPlayerError( - refreshCapReached: _refreshCapReached(), - errorCode: errorCode, - )) { + // 4xx on the HLS fetch means the current playback session is gone — a + // fresh GQL token + new query params can land on a different CDN edge + // with a working manifest. Skip the user-facing error and retry + // immediately (handleRefresh updates _lastRefreshTime itself). + if (statusCode != null && + statusCode >= 400 && + statusCode < 500 && + !_refreshCapReached()) { if (_userPaused) { // Don't yank a user-paused stream back to life — the refresh would // reload with autoplay. Defer until they resume. _pendingRefreshOnResume = true; return; } - final attempt = _totalRefreshAttempts + 1; - final delay = playerErrorRetryDelay(statusCode: statusCode); debugPrint( - 'NativeVideoStore: player error' - '${statusCode != null ? ' (HTTP $statusCode)' : ''}' - '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' - 'auto-refresh in ${delay.inMilliseconds}ms ' - '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', + 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' + 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', ); - _totalRefreshAttempts = attempt; - _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); - }); - } + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); return; } @@ -1329,22 +1304,9 @@ abstract class NativeVideoStoreBase if (_isInPipMode) return; _highLatencyCount = 0; updateStreamInfo(forceUpdate: true); - - 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; + if (!_userPaused && !_initializing && _controller != null) { + _controller!.play(); } - - _controller!.play(); } @override diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index afce5344..71fadd4f 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -67,6 +67,9 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { return super._blockingAds; } + @override + bool get isBlockingAds => blockingAds; + @override bool get _blockingAds => blockingAds; diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart deleted file mode 100644 index 72b83815..00000000 --- a/lib/screens/channel/video/playback_error_recovery.dart +++ /dev/null @@ -1,27 +0,0 @@ -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; -} diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index c2fb3ab8..c5cdb719 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -2,10 +2,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/home/search/search_results_categories.dart'; import 'package:frosty/screens/home/search/search_results_channels.dart'; -import 'package:frosty/screens/home/search/search_results_tags.dart'; import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -33,7 +31,6 @@ class _SearchState extends State { late final _searchStore = SearchStore( authStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), ); @override @@ -174,20 +171,6 @@ class _SearchState extends State { ), ), SearchResultsCategories(searchStore: _searchStore), - SliverToBoxAdapter( - child: Builder( - builder: (context) => SectionHeader( - 'Tags', - padding: EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, - 8, - 16 + MediaQuery.of(context).padding.right, - 8, - ), - ), - ), - ), - SearchResultsTags(searchStore: _searchStore), // Add padding for bottom navigation bar const SliverBottomPadding(), ], @@ -221,7 +204,7 @@ class _SearchState extends State { onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), - hintText: 'Search channels, categories, or tags', + hintText: 'Search channels or categories', suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty diff --git a/lib/screens/home/search/search_results_tags.dart b/lib/screens/home/search/search_results_tags.dart deleted file mode 100644 index 4a445385..00000000 --- a/lib/screens/home/search/search_results_tags.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/home/search/search_store.dart'; -import 'package:frosty/screens/home/search/tag_streams.dart'; -import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/skeleton_loader.dart'; -import 'package:mobx/mobx.dart'; - -class SearchResultsTags extends StatelessWidget { - final SearchStore searchStore; - - const SearchResultsTags({super.key, required this.searchStore}); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - final future = searchStore.tagFuture; - - if (future == null) { - if (searchStore.isSearching) { - return _tagSkeletons(); - } - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - switch (future.status) { - case FutureStatus.pending: - return _tagSkeletons(); - case FutureStatus.rejected: - return const SliverToBoxAdapter( - child: SizedBox( - height: 100.0, - child: AlertMessage( - message: 'Unable to load tags', - vertical: true, - ), - ), - ); - case FutureStatus.fulfilled: - final List tags = future.result as List? ?? const []; - - if (tags.isEmpty) { - return const SliverToBoxAdapter( - child: SizedBox( - height: 100.0, - child: AlertMessage( - message: 'No matching tags', - vertical: true, - ), - ), - ); - } - - return SliverList.builder( - itemCount: tags.length, - itemBuilder: (context, index) { - final tag = tags[index]; - return ListTile( - leading: const Icon(Icons.tag_rounded), - title: Text(tag), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings( - name: TagStreams.routeName, - ), - builder: (context) => TagStreams(tagName: tag), - ), - ); - }, - ); - }, - ); - } - }, - ); - } - - Widget _tagSkeletons() { - return SliverList.builder( - itemCount: 4, - itemBuilder: (context, index) => ListTile( - leading: SkeletonLoader( - width: 24, - height: 24, - borderRadius: BorderRadius.circular(4), - ), - title: SkeletonLoader( - height: 16, - borderRadius: BorderRadius.circular(4), - ), - ), - ); - } -} diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index 5969a495..e2d0509e 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -18,8 +17,6 @@ abstract class SearchStoreBase with Store { final TwitchApi twitchApi; - final TwitchGqlApi twitchGqlApi; - final textEditingController = TextEditingController(); final textFieldFocusNode = FocusNode(); @@ -43,14 +40,7 @@ abstract class SearchStoreBase with Store { @readonly ObservableFuture? _categoryFuture; - @readonly - ObservableFuture>? _tagFuture; - - SearchStoreBase({ - required this.authStore, - required this.twitchApi, - required this.twitchGqlApi, - }) { + SearchStoreBase({required this.authStore, required this.twitchApi}) { init(); } @@ -85,7 +75,6 @@ abstract class SearchStoreBase with Store { _isSearching = false; _channelFuture = null; _categoryFuture = null; - _tagFuture = null; return; } @@ -138,8 +127,6 @@ abstract class SearchStoreBase with Store { } return categories; }).asObservable(); - - _tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable(); } /// Find a specific channel provided the [query]. diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index ff03473e..ace3f885 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -109,26 +109,6 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } - late final _$_tagFutureAtom = Atom( - name: 'SearchStoreBase._tagFuture', - context: context, - ); - - ObservableFuture>? get tagFuture { - _$_tagFutureAtom.reportRead(); - return super._tagFuture; - } - - @override - ObservableFuture>? get _tagFuture => tagFuture; - - @override - set _tagFuture(ObservableFuture>? value) { - _$_tagFutureAtom.reportWrite(value, super._tagFuture, () { - super._tagFuture = value; - }); - } - late final _$initAsyncAction = AsyncAction( 'SearchStoreBase.init', context: context, diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart deleted file mode 100644 index c78c9051..00000000 --- a/lib/screens/home/search/tag_streams.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; -import 'package:frosty/screens/home/stream_list/streams_list.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; -import 'package:provider/provider.dart'; - -/// Streams matching a freeform tag (search → tap tag). -class TagStreams extends StatelessWidget { - static const routeName = 'TagStreams'; - - final String tagName; - - const TagStreams({super.key, required this.tagName}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final settingsStore = context.watch(); - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBody: true, - extendBodyBehindAppBar: true, - body: Stack( - children: [ - Positioned.fill( - child: Observer( - builder: (_) => StreamsList( - listType: ListType.tag, - freeformTag: tagName, - sortOrder: settingsStore.streamViewerSort, - showJumpButton: true, - ), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: BlurredContainer( - gradientDirection: GradientDirection.up, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - left: MediaQuery.of(context).padding.left, - right: MediaQuery.of(context).padding.right, - ), - child: SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, - ), - Expanded( - child: Text( - tagName, - style: theme.textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Observer( - builder: (_) => StreamSortButton( - sortOrder: settingsStore.streamViewerSort, - onChanged: (order) => - settingsStore.streamViewerSort = order, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index ab09ce96..f7d9f115 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -20,8 +20,6 @@ class LargeStreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const LargeStreamCard({ super.key, @@ -30,8 +28,6 @@ class LargeStreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -99,8 +95,6 @@ class LargeStreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/offline_channel_card.dart b/lib/screens/home/stream_list/offline_channel_card.dart index 2fe17d46..799a0c74 100644 --- a/lib/screens/home/stream_list/offline_channel_card.dart +++ b/lib/screens/home/stream_list/offline_channel_card.dart @@ -15,8 +15,6 @@ class OfflineChannelCard extends StatelessWidget { final bool showPinOption; final bool? isPinned; final bool showOfflineStatus; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const OfflineChannelCard({ super.key, @@ -24,8 +22,6 @@ class OfflineChannelCard extends StatelessWidget { required this.showPinOption, this.isPinned, this.showOfflineStatus = false, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -81,8 +77,6 @@ class OfflineChannelCard extends StatelessWidget { userId: channelInfo.broadcasterId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 0ffa914c..6c7a10c5 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -26,8 +26,6 @@ class StreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const StreamCard({ super.key, @@ -36,8 +34,6 @@ class StreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -228,8 +224,6 @@ class StreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index f7165dd4..94efe2f1 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/stream.dart'; @@ -24,41 +23,19 @@ abstract class ListStoreBase with Store { /// Twitch API service class for making requests. final TwitchApi twitchApi; - /// GQL client for tag streams and ascending viewer sort (Helix has neither). - final TwitchGqlApi? twitchGqlApi; - /// The type of list that this store is handling. final ListType listType; /// The category id to use when fetching streams if the [listType] is [ListType.category]. final String? categoryId; - /// Freeform tag name when [listType] is [ListType.tag]. - final String? freeformTag; - - /// Viewer sort for top / category / tag lists. - @observable - StreamViewerSort sortOrder; - /// The scroll controller used for handling scroll to top (if provided). /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. final ScrollController? scrollController; - /// When true, [dispose] will dispose [scrollController]. Shared controllers - /// from Home must not be disposed here. - final bool ownsScrollController; - /// The pagination cursor for the streams. 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. String? _offlineChannelsCursor; @@ -67,8 +44,7 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed - bool get hasMore => - isLoading == false && (_streamsCursor != null || _helixFill); + bool get hasMore => isLoading == false && _streamsCursor != null; @computed bool get isLoading => @@ -77,12 +53,6 @@ abstract class ListStoreBase with Store { _isCategoryDetailsLoading || _isOfflineChannelsLoading; - /// Selected stream language codes from settings (lowercase ISO 639-1). - List get _languageFilter => settingsStore.streamLanguages - .map((l) => l.trim().toLowerCase()) - .where((l) => l.isNotEmpty) - .toList(); - /// The loading status for pagination. /// The list of the fetched streams. @@ -175,22 +145,24 @@ abstract class ListStoreBase with Store { ReactionDisposer? _pinnedStreamsReactionDisposer; ReactionDisposer? _offlineChannelsExpansionDisposer; - ReactionDisposer? _languageFilterReactionDisposer; ListStoreBase({ required this.authStore, required this.settingsStore, required this.twitchApi, required this.listType, - this.twitchGqlApi, this.categoryId, - this.freeformTag, - this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, - this.ownsScrollController = false, }) { if (scrollController != null) { - scrollController!.addListener(_onScroll); + scrollController!.addListener(() { + if (scrollController!.position.atEdge || + scrollController!.position.outOfRange) { + showJumpButton = false; + } else { + showJumpButton = true; + } + }); } if (listType == ListType.followed) { @@ -223,241 +195,54 @@ abstract class ListStoreBase with Store { _getCategoryDetails(); } - // Reload directory lists when the language filter or sort changes. - if (listType != ListType.followed) { - sortOrder = settingsStore.streamViewerSort; - _languageFilterReactionDisposer = reaction( - (_) => [ - ...settingsStore.streamLanguages, - settingsStore.streamViewerSort, - ], - (_) { - sortOrder = settingsStore.streamViewerSort; - refreshStreams(); - }, - ); - } - getStreams(); } - void _onScroll() { - final controller = scrollController; - if (controller == null || !controller.hasClients) return; - - final position = controller.position; - if (position.atEdge || position.outOfRange) { - showJumpButton = false; - } else { - showJumpButton = true; - } - - // Backup pagination trigger (in addition to SliverList near-end builds). - if (hasMore && - position.pixels >= position.maxScrollExtent - 800) { - getStreams(); - } - } - - /// Prefer GQL for tag directories (both sorts) and for ascending Top/category. - bool get _preferGqlDirectory { - if (twitchGqlApi == null || _helixFill) return false; - if (listType == ListType.tag) return true; - return listType != ListType.followed && - sortOrder == StreamViewerSort.viewersAsc; - } - /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { - if (_isAllStreamsLoading) return; - _isAllStreamsLoading = true; try { - final languages = _languageFilter; - final isAsc = sortOrder == StreamViewerSort.viewersAsc; - final useGql = _preferGqlDirectory; - _lastPageWasGql = false; - - StreamsTwitch? newStreams; - if (listType == ListType.followed) { - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - } else if (useGql) { - newStreams = await _fetchGqlDirectory(languages: languages); - if (newStreams != null) { - _lastPageWasGql = true; - } + final StreamsTwitch newStreams; + switch (listType) { + case ListType.followed: + newStreams = await twitchApi.getFollowedStreams( + id: authStore.user.details!.id, + cursor: _streamsCursor, + ); + break; + case ListType.top: + newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + break; + case ListType.category: + newStreams = await twitchApi.getStreamsUnderCategory( + gameId: categoryId!, + cursor: _streamsCursor, + ); + break; } - // Helix: Top/category DESC, or GQL unavailable / integrity fallback. - newStreams ??= await twitchApi.getDirectoryStreams( - 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; - _helixFill = false; - _error = null; - return; - } - - // GQL pages are already server-sorted — keep API order. - // Helix ASC fill: sort page + full list so late matches land correctly. - final pageData = (!_lastPageWasGql && isAsc) - ? (List.of(newStreams.data)..sort( - (a, b) => a.viewerCount.compareTo(b.viewerCount), - )) - : newStreams.data; - - if (_allStreams.isEmpty && !_helixFill) { - _allStreams = pageData.asObservable(); - } else { - final existingIds = _allStreams.map((s) => s.userId).toSet(); - _allStreams.addAll( - pageData.where((s) => !existingIds.contains(s.userId)), - ); - } - - if (!_lastPageWasGql && isAsc && _allStreams.length > 1) { - final sorted = _allStreams.toList() - ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); - _allStreams = sorted.asObservable(); - } - - _streamsCursor = hasCursor ? next : null; if (_streamsCursor == null) { - _helixFill = false; - } else if (!_lastPageWasGql && isAsc) { - _helixFill = true; + _allStreams = newStreams.data.asObservable(); + } else { + _allStreams.addAll(newStreams.data); } + _streamsCursor = newStreams.pagination['cursor']; _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; debugPrint('Streams SocketException: No internet connection'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { - _streamsCursor = null; - _helixFill = false; - } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); - if (_allStreams.isNotEmpty) { - // Mid-scroll GQL failure: fall back to Helix fill when useful. - if (!_helixFill && - (listType == ListType.tag || isAscDirectory)) { - _helixFill = true; - _streamsCursor = null; - _error = null; - _isAllStreamsLoading = false; - await getStreams(); - return; - } - _streamsCursor = null; - _helixFill = false; - _error = null; - } } catch (e) { _error = 'Something went wrong loading streams'; debugPrint('Streams error: $e'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { - _streamsCursor = null; - _helixFill = false; - _error = null; - } - } finally { - _isAllStreamsLoading = false; } - } - /// GQL directory page with integrity retry. Returns null to trigger Helix. - Future _fetchGqlDirectory({ - required List languages, - }) async { - final gql = twitchGqlApi; - if (gql == null) return null; - - final langs = languages.isEmpty ? null : languages; - - try { - return await authStore.user.runWithGqlIntegrity( - gqlToken: authStore.gqlToken, - action: ({authToken, integrityToken, deviceId}) { - switch (listType) { - case ListType.tag: - return gql.getStreams( - freeformTags: [freeformTag!], - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.category: - return gql.getGameStreams( - gameId: categoryId!, - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.top: - return gql.getStreams( - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.followed: - throw StateError('GQL directory not used for followed'); - } - }, - ); - } on IntegrityCheckException catch (e) { - debugPrint('Streams GQL integrity: $e — falling back to Helix'); - _helixFill = true; - // Keep cursor null so Helix starts from the top (dedupe handles overlap). - _streamsCursor = null; - return null; - } - } - - bool get isAscDirectory => - sortOrder == StreamViewerSort.viewersAsc && - listType != ListType.followed; - - /// Persists viewer sort and reloads via the settings reaction. - @action - Future setSortOrder(StreamViewerSort order) async { - settingsStore.streamViewerSort = order; + _isAllStreamsLoading = false; } @action @@ -535,8 +320,6 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; - _helixFill = false; - _lastPageWasGql = false; await getStreams(); } @@ -566,14 +349,8 @@ abstract class ListStoreBase with Store { void dispose() { _pinnedStreamsReactionDisposer?.call(); _offlineChannelsExpansionDisposer?.call(); - _languageFilterReactionDisposer?.call(); - if (scrollController != null) { - scrollController!.removeListener(_onScroll); - if (ownsScrollController) { - scrollController!.dispose(); - } - } + scrollController?.dispose(); } } @@ -582,5 +359,4 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). -enum ListType { followed, top, category, tag } +enum ListType { followed, top, category } diff --git a/lib/screens/home/stream_list/stream_list_store.g.dart b/lib/screens/home/stream_list/stream_list_store.g.dart index 3ae70481..7fa78463 100644 --- a/lib/screens/home/stream_list/stream_list_store.g.dart +++ b/lib/screens/home/stream_list/stream_list_store.g.dart @@ -56,24 +56,6 @@ mixin _$ListStore on ListStoreBase, Store { name: 'ListStoreBase.offlineChannels', )).value; - late final _$sortOrderAtom = Atom( - name: 'ListStoreBase.sortOrder', - context: context, - ); - - @override - StreamViewerSort get sortOrder { - _$sortOrderAtom.reportRead(); - return super.sortOrder; - } - - @override - set sortOrder(StreamViewerSort value) { - _$sortOrderAtom.reportWrite(value, super.sortOrder, () { - super.sortOrder = value; - }); - } - late final _$_allStreamsAtom = Atom( name: 'ListStoreBase._allStreams', context: context, @@ -320,16 +302,6 @@ mixin _$ListStore on ListStoreBase, Store { return _$getStreamsAsyncAction.run(() => super.getStreams()); } - late final _$setSortOrderAsyncAction = AsyncAction( - 'ListStoreBase.setSortOrder', - context: context, - ); - - @override - Future setSortOrder(StreamViewerSort order) { - return _$setSortOrderAsyncAction.run(() => super.setSortOrder(order)); - } - late final _$getPinnedStreamsAsyncAction = AsyncAction( 'ListStoreBase.getPinnedStreams', context: context, @@ -377,7 +349,6 @@ mixin _$ListStore on ListStoreBase, Store { @override String toString() { return ''' -sortOrder: ${sortOrder}, showJumpButton: ${showJumpButton}, isOfflineChannelsExpanded: ${isOfflineChannelsExpanded}, hasMore: ${hasMore}, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index 46c1ffc1..ac97721d 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/offline_channel_card.dart'; @@ -26,12 +25,6 @@ class StreamsList extends StatefulWidget { final String? categoryId; - /// Freeform tag when [listType] is [ListType.tag]. - final String? freeformTag; - - /// Viewer sort for top / category / tag lists. - final StreamViewerSort sortOrder; - /// The scroll controller to use for scroll to top functionality. final ScrollController? scrollController; @@ -41,8 +34,6 @@ class StreamsList extends StatefulWidget { super.key, required this.listType, this.categoryId, - this.freeformTag, - this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, this.showJumpButton = false, }); @@ -72,24 +63,16 @@ class _StreamsListState extends State authStore: context.read(), settingsStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), listType: widget.listType, categoryId: widget.categoryId, - freeformTag: widget.freeformTag, - sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, - ownsScrollController: widget.scrollController == null && widget.showJumpButton, ); } @override void dispose() { - // ListStore disposes the controller when it owns it. - if (!_listStore.ownsScrollController) { - _scrollController?.dispose(); - } + _scrollController?.dispose(); WidgetsBinding.instance.removeObserver(this); - _listStore.dispose(); super.dispose(); } @@ -112,8 +95,6 @@ class _StreamsListState extends State ? kToolbarHeight : widget.listType == ListType.category ? kToolbarHeight + 122 - : widget.listType == ListType.tag - ? kToolbarHeight : 0.0; final topPadding = MediaQuery.of(context).padding.top + extraTopPadding; @@ -228,12 +209,9 @@ class _StreamsListState extends State ); } else { statusWidget = AlertMessage( - message: switch (widget.listType) { - ListType.followed => 'No followed streams', - ListType.tag => 'No streams with this tag', - ListType.category => 'No streams in this category', - ListType.top => 'No top streams', - }, + message: widget.listType == ListType.followed + ? 'No followed streams' + : 'No top streams', vertical: true, ); } @@ -260,12 +238,6 @@ class _StreamsListState extends State : _listStore.streams; 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( alignment: AlignmentDirectional.bottomCenter, @@ -326,9 +298,6 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, - initialIsFollowing: true, - onFollowChanged: - onFollowChanged, ) : StreamCard( key: ValueKey(stream.userId), @@ -340,9 +309,6 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, - initialIsFollowing: true, - onFollowChanged: - onFollowChanged, ); }, ); @@ -385,9 +351,6 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, - initialIsFollowing: - isFollowingTab ? true : null, - onFollowChanged: onFollowChanged, ) : StreamCard( key: ValueKey( @@ -399,9 +362,6 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, - initialIsFollowing: - isFollowingTab ? true : null, - onFollowChanged: onFollowChanged, ), ); }, @@ -450,8 +410,6 @@ class _StreamsListState extends State _listStore.offlineChannels[index], showPinOption: false, isPinned: false, - initialIsFollowing: true, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index d5a1edf0..fd1fc0c2 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; @@ -10,7 +9,6 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/skeleton_loader.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; import 'package:provider/provider.dart'; /// A widget that displays a list of streams under the provided [categoryId]. @@ -27,16 +25,12 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { - late final _settingsStore = context.read(); - late final _listStore = ListStore( listType: ListType.category, categoryId: widget.categoryId, authStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), - settingsStore: _settingsStore, - sortOrder: _settingsStore.streamViewerSort, + settingsStore: context.read(), ); @override @@ -51,13 +45,10 @@ class _CategoryStreamsState extends State { children: [ // Stream list content behind the pinned elements Positioned.fill( - child: Observer( - builder: (_) => StreamsList( - listType: ListType.category, - categoryId: widget.categoryId, - sortOrder: _settingsStore.streamViewerSort, - showJumpButton: true, - ), + child: StreamsList( + listType: ListType.category, + categoryId: widget.categoryId, + showJumpButton: true, ), ), // Single blurred background spanning app bar and category card @@ -73,82 +64,75 @@ class _CategoryStreamsState extends State { right: MediaQuery.of(context).padding.right, ), child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // App bar section - SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, - ), - const Spacer(), - Observer( - builder: (_) => StreamSortButton( - sortOrder: _settingsStore.streamViewerSort, - onChanged: (order) => - _settingsStore.streamViewerSort = order, + mainAxisSize: MainAxisSize.min, + children: [ + // App bar section + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, ), - ), - ], + ], + ), ), - ), - // Category card section - Observer( - builder: (_) { - if (_listStore.categoryDetails != null) { - return _TransparentCategoryCard( - category: _listStore.categoryDetails!, - ); - } else { - // Skeleton loader for category card - return Padding( - padding: const EdgeInsets.only( - bottom: 16, - left: 16, - right: 16, - ), - child: Row( - spacing: 12, - children: [ - const SizedBox( - width: 80, - child: AspectRatio( - aspectRatio: 3 / 4, - child: SkeletonLoader( - borderRadius: kCardBorderRadius, + // Category card section + Observer( + builder: (_) { + if (_listStore.categoryDetails != null) { + return _TransparentCategoryCard( + category: _listStore.categoryDetails!, + ); + } else { + // Skeleton loader for category card + return Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, + ), + child: Row( + spacing: 12, + children: [ + const SizedBox( + width: 80, + child: AspectRatio( + aspectRatio: 3 / 4, + child: SkeletonLoader( + borderRadius: kCardBorderRadius, + ), ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - SkeletonLoader( - height: 20, - width: double.infinity, - borderRadius: BorderRadius.circular(4), - ), - SkeletonLoader( - height: 16, - width: 120, - borderRadius: BorderRadius.circular(4), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 8, + children: [ + SkeletonLoader( + height: 20, + width: double.infinity, + borderRadius: BorderRadius.circular(4), + ), + SkeletonLoader( + height: 16, + width: 120, + borderRadius: BorderRadius.circular(4), + ), + ], + ), ), - ), - ], - ), - ); - } - }, - ), - ], - ), + ], + ), + ); + } + }, + ), + ], + ), ), ), ], diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 5f4d51a1..8fb8c9a5 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,13 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/top/categories/categories.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; -import 'package:provider/provider.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -25,30 +21,23 @@ class _TopSectionState extends State @override void initState() { super.initState(); - _tabBarController.addListener(() { - widget.homeStore.topSectionCurrentIndex = _tabBarController.index; - setState(() {}); - }); + _tabBarController.addListener( + () => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, + ); } @override Widget build(BuildContext context) { - final settingsStore = context.watch(); - final showSort = _tabBarController.index == 0; - return Stack( children: [ Positioned.fill( child: TabBarView( controller: _tabBarController, children: [ - Observer( - builder: (_) => StreamsList( - listType: ListType.top, - sortOrder: settingsStore.streamViewerSort, - scrollController: - widget.homeStore.topSectionScrollControllers[0], - ), + StreamsList( + listType: ListType.top, + scrollController: + widget.homeStore.topSectionScrollControllers[0], ), Categories( scrollController: @@ -68,26 +57,12 @@ class _TopSectionState extends State left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right, ), - child: Row( - children: [ - Expanded( - child: TabBar( - isScrollable: true, - controller: _tabBarController, - tabs: const [ - Tab(child: Text('Streams')), - Tab(child: Text('Categories')), - ], - ), - ), - if (showSort) - Observer( - builder: (_) => StreamSortButton( - sortOrder: settingsStore.streamViewerSort, - onChanged: (order) => - settingsStore.streamViewerSort = order, - ), - ), + child: TabBar( + isScrollable: true, + controller: _tabBarController, + tabs: const [ + Tab(child: Text('Streams')), + Tab(child: Text('Categories')), ], ), ), diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 96d5f8c9..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +// import removed: flutter_colorpicker import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/models/stream_languages.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/widgets/accent_color_setting.dart'; import 'package:frosty/widgets/external_browser_setting.dart'; +// import removed: frosty/widgets/dialog.dart import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/settings_page_layout.dart'; import 'package:frosty/widgets/theme_selection_setting.dart'; @@ -33,48 +34,6 @@ class GeneralSettings extends StatelessWidget { value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), - const SectionHeader('Stream languages'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - settingsStore.streamLanguages.isEmpty - ? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).' - : 'Only streams in the selected languages appear in Top, category, and tag lists.', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in StreamLanguages.all) - FilterChip( - label: Text(entry.label), - selected: settingsStore.streamLanguages.contains(entry.code), - onSelected: (selected) { - final next = [...settingsStore.streamLanguages]; - if (selected) { - if (!next.contains(entry.code)) next.add(entry.code); - } else { - next.remove(entry.code); - } - settingsStore.streamLanguages = next; - }, - ), - ], - ), - ), - if (settingsStore.streamLanguages.isNotEmpty) - Align( - alignment: Alignment.centerLeft, - child: TextButton( - onPressed: () => - settingsStore.streamLanguages = const [], - child: const Text('Clear language filter'), - ), - ), const SectionHeader('Links'), ExternalBrowserSetting(settingsStore: settingsStore), ], diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d4581732..d0be4504 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.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/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; @@ -42,9 +41,6 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. 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? _reconnectTimer; @@ -263,13 +259,9 @@ abstract class AuthBase with Store { ); } - AuthBase({ - required this.twitchApi, - required this.twitchGqlApi, - }) { + AuthBase({required this.twitchApi}) { user = UserStore( twitchApi: twitchApi, - twitchGqlApi: twitchGqlApi, hasModeratorScopes: () => hasModeratorScopes, ); } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 031b2c53..30599f99 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -1,4 +1,3 @@ -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:mobx/mobx.dart'; @@ -53,24 +52,6 @@ abstract class _SettingsStoreBase with Store { @observable var launchUrlExternal = defaultLaunchUrlExternal; - /// ISO 639-1 language codes for Top / category / tag stream lists. - /// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account. - static const defaultStreamLanguages = ['de']; - - @JsonKey(defaultValue: defaultStreamLanguages) - @observable - var streamLanguages = defaultStreamLanguages; - - /// Persistent viewer sort for Top / category / tag directories. - static const defaultStreamViewerSort = StreamViewerSort.viewersDesc; - - @JsonKey( - defaultValue: defaultStreamViewerSort, - unknownEnumValue: StreamViewerSort.viewersDesc, - ) - @observable - var streamViewerSort = defaultStreamViewerSort; - @action void resetGeneralSettings() { themeType = defaultThemeType; @@ -80,8 +61,6 @@ abstract class _SettingsStoreBase with Store { showThumbnails = defaultShowThumbnails; launchUrlExternal = defaultLaunchUrlExternal; - streamLanguages = defaultStreamLanguages; - streamViewerSort = defaultStreamViewerSort; } // * Video Settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 797941cf..a583ac94 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -20,18 +20,6 @@ SettingsStore _$SettingsStoreFromJson( ..showThumbnails = json['showThumbnails'] as bool? ?? true ..largeStreamCard = json['largeStreamCard'] as bool? ?? false ..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false - ..streamLanguages = - (json['streamLanguages'] as List?) - ?.map((e) => e as String) - .toList() ?? - ['de'] - ..streamViewerSort = - $enumDecodeNullable( - _$StreamViewerSortEnumMap, - json['streamViewerSort'], - unknownValue: StreamViewerSort.viewersDesc, - ) ?? - StreamViewerSort.viewersDesc ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true @@ -116,8 +104,6 @@ Map _$SettingsStoreToJson( 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, 'launchUrlExternal': instance.launchUrlExternal, - 'streamLanguages': instance.streamLanguages, - 'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, @@ -173,11 +159,6 @@ const _$ThemeTypeEnumMap = { ThemeType.dark: 'dark', }; -const _$StreamViewerSortEnumMap = { - StreamViewerSort.viewersDesc: 'viewersDesc', - StreamViewerSort.viewersAsc: 'viewersAsc', -}; - const _$TimestampTypeEnumMap = { TimestampType.disabled: 'disabled', TimestampType.twelve: 'twelve', @@ -297,42 +278,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$streamLanguagesAtom = Atom( - name: '_SettingsStoreBase.streamLanguages', - context: context, - ); - - @override - List get streamLanguages { - _$streamLanguagesAtom.reportRead(); - return super.streamLanguages; - } - - @override - set streamLanguages(List value) { - _$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () { - super.streamLanguages = value; - }); - } - - late final _$streamViewerSortAtom = Atom( - name: '_SettingsStoreBase.streamViewerSort', - context: context, - ); - - @override - StreamViewerSort get streamViewerSort { - _$streamViewerSortAtom.reportRead(); - return super.streamViewerSort; - } - - @override - set streamViewerSort(StreamViewerSort value) { - _$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () { - super.streamViewerSort = value; - }); - } - late final _$showVideoAtom = Atom( name: '_SettingsStoreBase.showVideo', context: context, @@ -1316,8 +1261,6 @@ accentColor: ${accentColor}, showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, -streamLanguages: ${streamLanguages}, -streamViewerSort: ${streamViewerSort}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index de29e1da..60cd7dd7 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -1,12 +1,6 @@ -import 'dart:math'; - -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:mobx/mobx.dart'; -import 'package:shared_preferences/shared_preferences.dart'; part 'user_store.g.dart'; @@ -14,7 +8,6 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; - final TwitchGqlApi twitchGqlApi; /// Whether the active auth token includes Frosty's moderator OAuth scopes. final bool Function() hasModeratorScopes; @@ -31,20 +24,10 @@ abstract class UserStoreBase with Store { @readonly var _moderatedChannels = ObservableList(); - /// Cached follow state for channels queried this session (id → following). - final _followCache = ObservableMap(); - - static const _deviceIdKey = 'twitch_gql_device_id'; - - String? _deviceId; - String? _integrityToken; - int _integrityExpirationMs = 0; - ReactionDisposer? _disposeReaction; UserStoreBase({ required this.twitchApi, - required this.twitchGqlApi, required this.hasModeratorScopes, }); @@ -78,172 +61,6 @@ abstract class UserStoreBase with Store { ); } - /// Whether we already know follow state for [channelId] this session. - bool? cachedIsFollowing(String channelId) => _followCache[channelId]; - - @action - Future 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 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 unfollow({ - required String targetId, - required String gqlToken, - }) async { - await _followMutation( - targetId: targetId, - gqlToken: gqlToken, - follow: false, - ); - _followCache[targetId] = false; - } - - Future _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 runWithGqlIntegrity({ - required String? gqlToken, - required Future 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 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 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 _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 Future block({ required String targetId, @@ -344,8 +161,6 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); - _followCache.clear(); - clearIntegrity(); _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index e138aa77..084a0bda 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -79,42 +79,6 @@ mixin _$UserStore on UserStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } - late final _$isFollowingChannelAsyncAction = AsyncAction( - 'UserStoreBase.isFollowingChannel', - context: context, - ); - - @override - Future isFollowingChannel(String channelId) { - return _$isFollowingChannelAsyncAction.run( - () => super.isFollowingChannel(channelId), - ); - } - - late final _$followAsyncAction = AsyncAction( - 'UserStoreBase.follow', - context: context, - ); - - @override - Future 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 unfollow({required String targetId, required String gqlToken}) { - return _$unfollowAsyncAction.run( - () => super.unfollow(targetId: targetId, gqlToken: gqlToken), - ); - } - late final _$blockAsyncAction = AsyncAction( 'UserStoreBase.block', context: context, diff --git a/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart deleted file mode 100644 index c90b8c5a..00000000 --- a/lib/widgets/follow_button.dart +++ /dev/null @@ -1,206 +0,0 @@ -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? onFollowChanged; - - const FollowButton({ - super.key, - required this.authStore, - required this.userId, - required this.displayName, - this.initialIsFollowing, - this.onFollowChanged, - }); - - @override - State createState() => _FollowButtonState(); -} - -class _FollowButtonState extends State { - 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 _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 _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 _promptLinkWebSession() { - return showDialog( - 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'), - ], - ), - ); - } -} diff --git a/lib/widgets/follow_channel_controller.dart b/lib/widgets/follow_channel_controller.dart deleted file mode 100644 index 46c45ff2..00000000 --- a/lib/widgets/follow_channel_controller.dart +++ /dev/null @@ -1,150 +0,0 @@ -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 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 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 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 _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(); -} diff --git a/lib/widgets/integrity_webview_dialog.dart b/lib/widgets/integrity_webview_dialog.dart deleted file mode 100644 index 9d9d9054..00000000 --- a/lib/widgets/integrity_webview_dialog.dart +++ /dev/null @@ -1,232 +0,0 @@ -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 showIntegrityWebViewDialog({ - required BuildContext context, - required String gqlToken, -}) { - return showDialog( - 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 createState() => _IntegrityWebViewDialogState(); -} - -class _IntegrityWebViewDialogState extends State { - 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 _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 _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; - 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'), - ), - ], - ); - } -} diff --git a/lib/widgets/stream_sort_button.dart b/lib/widgets/stream_sort_button.dart deleted file mode 100644 index 883fc363..00000000 --- a/lib/widgets/stream_sort_button.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; - -/// Toggles viewer-count sort between descending (default) and ascending. -class StreamSortButton extends StatelessWidget { - final StreamViewerSort sortOrder; - final ValueChanged onChanged; - - const StreamSortButton({ - super.key, - required this.sortOrder, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final isAsc = sortOrder == StreamViewerSort.viewersAsc; - - return PopupMenuButton( - tooltip: 'Sort by viewers', - icon: Icon( - isAsc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, - ), - initialValue: sortOrder, - onSelected: onChanged, - itemBuilder: (context) => [ - const PopupMenuItem( - value: StreamViewerSort.viewersDesc, - child: Text('Most viewers'), - ), - const PopupMenuItem( - value: StreamViewerSort.viewersAsc, - child: Text('Fewest viewers'), - ), - ], - ); - } -} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index 19bc1f90..d86c75ae 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -1,18 +1,12 @@ import 'package:flutter/material.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/settings_store.dart'; -import 'package:frosty/widgets/alert_message.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:webview_flutter/webview_flutter.dart'; -class UserActionsModal extends StatefulWidget { +class UserActionsModal extends StatelessWidget { final AuthStore authStore; final String name; final String userLogin; @@ -20,12 +14,6 @@ class UserActionsModal extends StatefulWidget { final bool showPinOption; 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? onFollowChanged; - /// Optional moderation actions (shown at the top) for chat contexts. Kept as /// an injected widget so this generic modal stays chat-agnostic. final Widget? moderationActions; @@ -38,224 +26,51 @@ class UserActionsModal extends StatefulWidget { required this.userId, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, this.moderationActions, }); - @override - State createState() => _UserActionsModalState(); -} - -class _UserActionsModalState extends State { - 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 _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 _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 _promptLinkWebSession() { - return showDialog( - 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 Widget build(BuildContext context) { - final showFollowTile = _follow.shouldShow; - return ListView( primary: false, shrinkWrap: true, children: [ - ?widget.moderationActions, - if (widget.showPinOption) + ?moderationActions, + if (showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), - title: Text( - '${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}', - ), + title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'), onTap: () { - if (widget.isPinned == true) { + if (isPinned == true) { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds - ..remove(widget.userId), + ..remove(userId), ]; } else { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds, - widget.userId, + userId, ]; } Navigator.pop(context); }, ), - 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) + if (authStore.isLoggedIn) ListTile( leading: const Icon(Icons.block_rounded), - onTap: () => widget.authStore + onTap: () => authStore .showBlockDialog( context, - targetUser: widget.name, - targetUserId: widget.userId, + targetUser: name, + targetUserId: userId, ) .then((_) { if (context.mounted) { Navigator.pop(context); } }), - title: Text('Block ${widget.name}'), + title: Text('Block $name'), ), ListTile( leading: const Icon(Icons.outlined_flag_rounded), @@ -286,7 +101,7 @@ class _UserActionsModalState extends State { icon: Icon(Icons.adaptive.arrow_back_rounded), onPressed: Navigator.of(context).pop, ), - title: Text('Report ${widget.name}'), + title: Text('Report $name'), ), body: Stack( children: [ @@ -303,7 +118,7 @@ class _UserActionsModalState extends State { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest( Uri.parse( - 'https://www.twitch.tv/${widget.userLogin}/report', + 'https://www.twitch.tv/$userLogin/report', ), ) ..setNavigationDelegate( @@ -339,7 +154,7 @@ class _UserActionsModalState extends State { }, ), ), - title: Text('Report ${widget.name}'), + title: Text('Report $name'), ), ], ); diff --git a/pubspec.yaml b/pubspec.yaml index 1899d872..c5f510e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.3.1+81 +version: 5.2.1+71 environment: sdk: ^3.9.0 diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 4180a464..7727e23f 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -587,55 +587,4 @@ 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': {}, - }), - 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': [], - 'pagination': {}, - }), - queryParameters: { - 'user_id': '12345', - 'broadcaster_id': '999', - 'first': '1', - }, - ); - - expect( - await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), - isFalse, - ); - }); - }); } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart deleted file mode 100644 index 00efa120..00000000 --- a/test/apis/twitch_gql_follow_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -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()), - ); - }); - }); - - 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()), - ); - }); - }); -} diff --git a/test/apis/twitch_gql_streams_test.dart b/test/apis/twitch_gql_streams_test.dart deleted file mode 100644 index 00888e9f..00000000 --- a/test/apis/twitch_gql_streams_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -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 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()), - ); - }); - - 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); - }); - }); -} diff --git a/test/apis/twitch_integrity_test.dart b/test/apis/twitch_integrity_test.dart deleted file mode 100644 index a07ec031..00000000 --- a/test/apis/twitch_integrity_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; - -String _tokenFor(Map 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); - }); - }); -} diff --git a/test/screens/channel/video/playback_error_recovery_test.dart b/test/screens/channel/video/playback_error_recovery_test.dart deleted file mode 100644 index adc80903..00000000 --- a/test/screens/channel/video/playback_error_recovery_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -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, - ); - }); - }); -} diff --git a/test/screens/home/stream_list/stream_list_store_test.dart b/test/screens/home/stream_list/stream_list_store_test.dart deleted file mode 100644 index 1a066c66..00000000 --- a/test/screens/home/stream_list/stream_list_store_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -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([]); - }); - - 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 waitUntilIdle(ListStore store) async { - await Future.delayed(Duration.zero); - for (var i = 0; i < 50 && store.isLoading; i++) { - await Future.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 => [], - ); - }); - - 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(); - }); - }); -} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 4897d3ad..4affd3d7 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,27 +1,16 @@ -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/user_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() { late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; setUp(() { - SharedPreferences.setMockInitialValues({}); api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); when(() => api.getUserInfo()).thenAnswer( (_) async => const UserTwitch( '12345', @@ -38,39 +27,42 @@ void main() { ); }); - UserStore makeStore({required bool hasModeratorScopes}) => UserStore( - twitchApi: api, - twitchGqlApi: gqlApi, - hasModeratorScopes: () => hasModeratorScopes, - ); - group('UserStore moderated channels gating', () { test('init skips moderated-channels without moderator scopes', () async { - final userStore = makeStore(hasModeratorScopes: false); + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); - await userStore.init(); + await store.init(); await Future.delayed(Duration.zero); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - expect(userStore.moderatedChannels, isEmpty); + expect(store.moderatedChannels, isEmpty); }); test('init fetches moderated-channels when moderator scopes exist', () async { - final userStore = makeStore(hasModeratorScopes: true); + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => true, + ); - await userStore.init(); + await store.init(); await Future.delayed(Duration.zero); verify(() => api.getModeratedChannels(id: '12345')).called(1); - expect(userStore.moderatedChannels, ['999']); + expect(store.moderatedChannels, ['999']); }); test('refreshModeratedChannels no-ops without moderator scopes', () async { - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); clearInteractions(api); - await userStore.refreshModeratedChannels(); + await store.refreshModeratedChannels(); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); }); @@ -83,10 +75,13 @@ void main() { ), ).thenAnswer((_) async => true); - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); - final result = await userStore.deleteMessage( + final result = await store.deleteMessage( broadcasterId: '12345', messageId: 'msg', ); @@ -101,168 +96,4 @@ 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( - 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( - gqlToken: null, - action: ({authToken, integrityToken, deviceId}) async { - throw const IntegrityCheckException('failed integrity check'); - }, - ), - throwsA(isA()), - ); - 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( - gqlToken: 'web-token', - action: ({authToken, integrityToken, deviceId}) async { - expect(deviceId, 'webview-device'); - expect(integrityToken, isNotNull); - return deviceId!; - }, - ); - expect(deviceId, 'webview-device'); - }); - }); } diff --git a/test/widgets/follow_channel_controller_test.dart b/test/widgets/follow_channel_controller_test.dart deleted file mode 100644 index bdc88570..00000000 --- a/test/widgets/follow_channel_controller_test.dart +++ /dev/null @@ -1,254 +0,0 @@ -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 => [], - ); - - 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.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()), - ); - }); - - 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()), - ); - 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()), - ); - expect(controller.actionInProgress, isFalse); - }); - }); -}