Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 378c230815 | |||
| eb39a4ac00 | |||
| 59290b23b1 | |||
| 9a08c40136 |
11 changed files with 924 additions and 186 deletions
|
|
@ -1,3 +1,23 @@
|
||||||
|
## v5.3.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
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v5.2.9
|
## v5.2.9
|
||||||
|
|
||||||
October 7, 2026
|
October 7, 2026
|
||||||
|
|
@ -6,24 +26,3 @@ October 7, 2026
|
||||||
|
|
||||||
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
- 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 follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.2.8
|
|
||||||
|
|
||||||
October 7, 2026
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.2.7
|
|
||||||
|
|
||||||
October 1, 2026
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/channel_info_header.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video.dart';
|
import 'package:frosty/screens/channel/video/native_video.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
|
|
@ -746,6 +747,14 @@ class _VideoChatState extends State<VideoChat>
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: SizedBox(),
|
child: SizedBox(),
|
||||||
),
|
),
|
||||||
|
ChannelInfoHeader(
|
||||||
|
videoStore: _videoStore,
|
||||||
|
authStore: context.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
displayName: widget.userName,
|
||||||
|
isInSharedChatMode:
|
||||||
|
_chatStore.isInSharedChatMode,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
63
lib/screens/channel/video/channel_info_header.dart
Normal file
63
lib/screens/channel/video/channel_info_header.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/follow_button.dart';
|
||||||
|
|
||||||
|
/// Channel metadata + follow control shown under the player (portrait).
|
||||||
|
class ChannelInfoHeader extends StatelessWidget {
|
||||||
|
final VideoPlayerInterface videoStore;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool isInSharedChatMode;
|
||||||
|
|
||||||
|
const ChannelInfoHeader({
|
||||||
|
super.key,
|
||||||
|
required this.videoStore,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.isInSharedChatMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
|
child: Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final streamInfo = videoStore.streamInfo;
|
||||||
|
final offline = streamInfo == null;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StreamInfoBar(
|
||||||
|
streamInfo: streamInfo,
|
||||||
|
offlineChannelInfo: videoStore.offlineChannelInfo,
|
||||||
|
displayName: displayName,
|
||||||
|
isCompact: true,
|
||||||
|
isOffline: offline,
|
||||||
|
isInSharedChatMode: isInSharedChatMode,
|
||||||
|
showTextShadows: false,
|
||||||
|
tooltipTriggerMode: TooltipTriggerMode.longPress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FollowButton(
|
||||||
|
authStore: authStore,
|
||||||
|
userId: userId,
|
||||||
|
displayName: displayName,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import 'package:frosty/models/playback_access_token.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/playback_error_recovery.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -683,26 +684,44 @@ abstract class NativeVideoStoreBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx on the HLS fetch means the current playback session is gone — a
|
// Mid-stream failures (expired HLS session, ExoPlayer "Source error"
|
||||||
// fresh GQL token + new query params can land on a different CDN edge
|
// after screen-off / network switch, CDN blips). A fresh token + reload
|
||||||
// with a working manifest. Skip the user-facing error and retry
|
// usually fixes these — only pin the Reload overlay once retries are
|
||||||
// immediately (handleRefresh updates _lastRefreshTime itself).
|
// spent. 4xx refreshes immediately; network/source waits briefly so the
|
||||||
if (statusCode != null &&
|
// interface can come back.
|
||||||
statusCode >= 400 &&
|
if (shouldAutoRecoverFromPlayerError(
|
||||||
statusCode < 500 &&
|
refreshCapReached: _refreshCapReached(),
|
||||||
!_refreshCapReached()) {
|
errorCode: errorCode,
|
||||||
|
)) {
|
||||||
if (_userPaused) {
|
if (_userPaused) {
|
||||||
// Don't yank a user-paused stream back to life — the refresh would
|
// Don't yank a user-paused stream back to life — the refresh would
|
||||||
// reload with autoplay. Defer until they resume.
|
// reload with autoplay. Defer until they resume.
|
||||||
_pendingRefreshOnResume = true;
|
_pendingRefreshOnResume = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final attempt = _totalRefreshAttempts + 1;
|
||||||
|
final delay = playerErrorRetryDelay(statusCode: statusCode);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh '
|
'NativeVideoStore: player error'
|
||||||
'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})',
|
'${statusCode != null ? ' (HTTP $statusCode)' : ''}'
|
||||||
|
'${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, '
|
||||||
|
'auto-refresh in ${delay.inMilliseconds}ms '
|
||||||
|
'(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})',
|
||||||
);
|
);
|
||||||
_totalRefreshAttempts++;
|
_totalRefreshAttempts = attempt;
|
||||||
_handleRefresh(userInitiated: false);
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_initRetryTimer?.cancel();
|
||||||
|
final generation = _initGeneration;
|
||||||
|
if (delay == Duration.zero) {
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
} else {
|
||||||
|
_initRetryTimer = Timer(delay, () {
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
// _handleRefresh parks behind _pendingRefreshOnResume when paused.
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1310,9 +1329,22 @@ abstract class NativeVideoStoreBase
|
||||||
if (_isInPipMode) return;
|
if (_isInPipMode) return;
|
||||||
_highLatencyCount = 0;
|
_highLatencyCount = 0;
|
||||||
updateStreamInfo(forceUpdate: true);
|
updateStreamInfo(forceUpdate: true);
|
||||||
if (!_userPaused && !_initializing && _controller != null) {
|
|
||||||
_controller!.play();
|
if (_userPaused || _initializing) return;
|
||||||
|
|
||||||
|
// After screen-off / a network drop the native player is often in an
|
||||||
|
// error state (ExoPlayer "Source error"). play() alone cannot revive a
|
||||||
|
// dead session — refresh like the Reload button would.
|
||||||
|
if (_error != null ||
|
||||||
|
_pendingRefreshOnResume ||
|
||||||
|
_controller == null ||
|
||||||
|
!_controllerInitialized) {
|
||||||
|
_pendingRefreshOnResume = false;
|
||||||
|
unawaited(_handleRefresh(userInitiated: false));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_controller!.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player
|
||||||
|
/// error instead of pinning the Reload overlay.
|
||||||
|
///
|
||||||
|
/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a
|
||||||
|
/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset]
|
||||||
|
/// has its own dispose+rebuild path and is excluded here.
|
||||||
|
bool shouldAutoRecoverFromPlayerError({
|
||||||
|
required bool refreshCapReached,
|
||||||
|
String? errorCode,
|
||||||
|
}) {
|
||||||
|
if (refreshCapReached) return false;
|
||||||
|
if (errorCode == 'mediaServicesWereReset') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delay before an automatic refresh for [statusCode].
|
||||||
|
///
|
||||||
|
/// Token/CDN 4xx can refresh immediately; network/source failures get a short
|
||||||
|
/// pause so the interface can come back after a switch or screen wake.
|
||||||
|
Duration playerErrorRetryDelay({int? statusCode}) {
|
||||||
|
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
|
||||||
|
return Duration.zero;
|
||||||
|
}
|
||||||
|
return VideoTimingConstants.initRetryDelay;
|
||||||
|
}
|
||||||
206
lib/widgets/follow_button.dart
Normal file
206
lib/widgets/follow_button.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
|
|
||||||
|
/// Compact Follow / Unfollow control for channel headers.
|
||||||
|
class FollowButton extends StatefulWidget {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
|
const FollowButton({
|
||||||
|
super.key,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FollowButton> createState() => _FollowButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FollowButtonState extends State<FollowButton> {
|
||||||
|
late final FollowChannelController _controller = FollowChannelController(
|
||||||
|
authStore: widget.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
|
onChanged: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPressed() async {
|
||||||
|
if (!_controller.canFollow ||
|
||||||
|
_controller.isFollowing == null ||
|
||||||
|
_controller.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _controller.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: wasFollowing,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptLinkWebSession() {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Web session required',
|
||||||
|
message:
|
||||||
|
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showResultSnack(bool nowFollowing) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(
|
||||||
|
message: nowFollowing
|
||||||
|
? 'Followed ${widget.displayName}'
|
||||||
|
: 'Unfollowed ${widget.displayName}',
|
||||||
|
centered: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_controller.shouldShow) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = _controller.isFollowing == true;
|
||||||
|
final busy = _controller.loading || _controller.actionInProgress;
|
||||||
|
|
||||||
|
return FilledButton.tonal(
|
||||||
|
onPressed: busy ? null : _onPressed,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
following
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
Text(following ? 'Unfollow' : 'Follow'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
lib/widgets/follow_channel_controller.dart
Normal file
150
lib/widgets/follow_channel_controller.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
|
/// Shared follow/unfollow state + GQL mutations for channel UI.
|
||||||
|
///
|
||||||
|
/// Owns loading/action flags and the session cache via [AuthStore.user].
|
||||||
|
/// Callers handle UI (integrity WebView, login prompt, snackbars).
|
||||||
|
class FollowChannelController {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final void Function() onChanged;
|
||||||
|
|
||||||
|
bool? isFollowing;
|
||||||
|
bool loading = false;
|
||||||
|
bool actionInProgress = false;
|
||||||
|
|
||||||
|
FollowChannelController({
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isSelf =>
|
||||||
|
authStore.user.details?.id != null &&
|
||||||
|
authStore.user.details!.id == userId;
|
||||||
|
|
||||||
|
bool get canFollow => authStore.isLoggedIn && !isSelf;
|
||||||
|
|
||||||
|
/// Whether the follow control should be visible (loading or known state).
|
||||||
|
bool get shouldShow => canFollow && (loading || isFollowing != null);
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
if (!canFollow) return;
|
||||||
|
if (initialIsFollowing != null) {
|
||||||
|
isFollowing = initialIsFollowing;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadFollowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadFollowState() async {
|
||||||
|
final cached = authStore.user.cachedIsFollowing(userId);
|
||||||
|
if (cached != null) {
|
||||||
|
isFollowing = cached;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
onChanged();
|
||||||
|
try {
|
||||||
|
final following = await authStore.user.isFollowingChannel(userId);
|
||||||
|
isFollowing = following;
|
||||||
|
} catch (_) {
|
||||||
|
// Leave isFollowing null so the control stays hidden.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles follow. Returns the new following state on success.
|
||||||
|
///
|
||||||
|
/// Throws [IntegrityCheckException] when a WebView integrity mint is needed,
|
||||||
|
/// [ApiException] for API errors, or [StateError] when not ready / no token.
|
||||||
|
Future<bool> toggle() async {
|
||||||
|
if (actionInProgress || isFollowing == null) {
|
||||||
|
throw StateError('Follow toggle not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = isFollowing!;
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _runMutation(following: following, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !following;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a WebView integrity mint, apply credentials and retry [toggle]'s
|
||||||
|
/// intended mutation (still based on the pre-toggle [wasFollowing] state).
|
||||||
|
Future<bool> retryAfterIntegrity({
|
||||||
|
required bool wasFollowing,
|
||||||
|
required GqlIntegrityCredentials credentials,
|
||||||
|
}) async {
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.user.applyWebIntegrity(credentials);
|
||||||
|
await _runMutation(following: wasFollowing, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !wasFollowing;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runMutation({
|
||||||
|
required bool following,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
if (following) {
|
||||||
|
await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
} else {
|
||||||
|
await authStore.user.follow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearActionInProgress() {
|
||||||
|
if (!actionInProgress) return;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when follow needs a linked Twitch web (GQL) session.
|
||||||
|
class FollowNeedsWebSessionException implements Exception {
|
||||||
|
const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/alert_message.dart';
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
@ -47,178 +48,119 @@ class UserActionsModal extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserActionsModalState extends State<UserActionsModal> {
|
class _UserActionsModalState extends State<UserActionsModal> {
|
||||||
bool? _isFollowing;
|
late final FollowChannelController _follow = FollowChannelController(
|
||||||
bool _followLoading = false;
|
authStore: widget.authStore,
|
||||||
bool _followActionInProgress = false;
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
bool get _isSelf =>
|
onChanged: () {
|
||||||
widget.authStore.user.details?.id != null &&
|
if (mounted) setState(() {});
|
||||||
widget.authStore.user.details!.id == widget.userId;
|
},
|
||||||
|
);
|
||||||
bool get _canFollow =>
|
|
||||||
widget.authStore.isLoggedIn && !_isSelf;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (!_canFollow) return;
|
_follow.init();
|
||||||
if (widget.initialIsFollowing != null) {
|
|
||||||
_isFollowing = widget.initialIsFollowing;
|
|
||||||
} else {
|
|
||||||
_loadFollowState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadFollowState() async {
|
|
||||||
final cached = widget.authStore.user.cachedIsFollowing(widget.userId);
|
|
||||||
if (cached != null) {
|
|
||||||
setState(() => _isFollowing = cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _followLoading = true);
|
|
||||||
try {
|
|
||||||
final following = await widget.authStore.user.isFollowingChannel(
|
|
||||||
widget.userId,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isFollowing = following;
|
|
||||||
_followLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _followLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFollow() async {
|
Future<void> _toggleFollow() async {
|
||||||
if (_followActionInProgress || _isFollowing == null) return;
|
if (!_follow.canFollow ||
|
||||||
|
_follow.isFollowing == null ||
|
||||||
|
_follow.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _follow.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _follow.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_popWithSnack(
|
||||||
|
nowFollowing
|
||||||
|
? 'Followed ${widget.name}'
|
||||||
|
: 'Unfollowed ${widget.name}',
|
||||||
|
);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.name}'
|
||||||
|
: 'Could not follow ${widget.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
final gqlToken = widget.authStore.gqlToken;
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
if (gqlToken == null) {
|
if (gqlToken == null) {
|
||||||
|
_follow.clearActionInProgress();
|
||||||
await _promptLinkWebSession();
|
await _promptLinkWebSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final following = _isFollowing!;
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
setState(() => _followActionInProgress = true);
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
try {
|
if (credentials == null) {
|
||||||
await _runFollowMutation(following: following, gqlToken: gqlToken);
|
_follow.clearActionInProgress();
|
||||||
} on IntegrityCheckException {
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
if (!mounted) return;
|
|
||||||
// Dio /integrity is almost always bad_bot — mint via WebView like Xtra.
|
|
||||||
final credentials = await showIntegrityWebViewDialog(
|
|
||||||
context: context,
|
|
||||||
gqlToken: gqlToken,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
if (credentials == null) {
|
|
||||||
setState(() => _followActionInProgress = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: AlertMessage(
|
|
||||||
message: 'Follow cancelled — Twitch verification required',
|
|
||||||
centered: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await widget.authStore.user.applyWebIntegrity(credentials);
|
|
||||||
await _runFollowMutation(following: following, gqlToken: gqlToken);
|
|
||||||
} on ApiException catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _followActionInProgress = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: AlertMessage(message: e.message, centered: false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _followActionInProgress = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: AlertMessage(
|
|
||||||
message: following
|
|
||||||
? 'Could not unfollow ${widget.name}'
|
|
||||||
: 'Could not follow ${widget.name}',
|
|
||||||
centered: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} on ApiException catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _followActionInProgress = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: AlertMessage(message: e.message, centered: false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _followActionInProgress = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: AlertMessage(
|
|
||||||
message: following
|
|
||||||
? 'Could not unfollow ${widget.name}'
|
|
||||||
: 'Could not follow ${widget.name}',
|
|
||||||
centered: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
try {
|
||||||
|
final nowFollowing = await _follow.retryAfterIntegrity(
|
||||||
final nowFollowing = !following;
|
wasFollowing: wasFollowing,
|
||||||
setState(() {
|
credentials: credentials,
|
||||||
_isFollowing = nowFollowing;
|
);
|
||||||
_followActionInProgress = false;
|
if (!mounted) return;
|
||||||
});
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
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);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: AlertMessage(
|
content: AlertMessage(message: message, centered: false),
|
||||||
message: nowFollowing
|
|
||||||
? 'Followed ${widget.name}'
|
|
||||||
: 'Unfollowed ${widget.name}',
|
|
||||||
centered: false,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runFollowMutation({
|
void _showErrorSnack(String message) {
|
||||||
required bool following,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
required String gqlToken,
|
SnackBar(
|
||||||
}) async {
|
content: AlertMessage(message: message, centered: false),
|
||||||
if (following) {
|
),
|
||||||
await widget.authStore.user.unfollow(
|
);
|
||||||
targetId: widget.userId,
|
|
||||||
gqlToken: gqlToken,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await widget.authStore.user.follow(
|
|
||||||
targetId: widget.userId,
|
|
||||||
gqlToken: gqlToken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _promptLinkWebSession() {
|
Future<void> _promptLinkWebSession() {
|
||||||
|
|
@ -249,7 +191,7 @@ class _UserActionsModalState extends State<UserActionsModal> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showFollowTile = _canFollow && (_followLoading || _isFollowing != null);
|
final showFollowTile = _follow.shouldShow;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
primary: false,
|
primary: false,
|
||||||
|
|
@ -280,23 +222,23 @@ class _UserActionsModalState extends State<UserActionsModal> {
|
||||||
),
|
),
|
||||||
if (showFollowTile)
|
if (showFollowTile)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: _followLoading || _followActionInProgress
|
leading: _follow.loading || _follow.actionInProgress
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
_isFollowing == true
|
_follow.isFollowing == true
|
||||||
? Icons.heart_broken_rounded
|
? Icons.heart_broken_rounded
|
||||||
: Icons.favorite_rounded,
|
: Icons.favorite_rounded,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
_isFollowing == true
|
_follow.isFollowing == true
|
||||||
? 'Unfollow ${widget.name}'
|
? 'Unfollow ${widget.name}'
|
||||||
: 'Follow ${widget.name}',
|
: 'Follow ${widget.name}',
|
||||||
),
|
),
|
||||||
enabled: !_followLoading && !_followActionInProgress,
|
enabled: !_follow.loading && !_follow.actionInProgress,
|
||||||
onTap: _toggleFollow,
|
onTap: _toggleFollow,
|
||||||
),
|
),
|
||||||
if (widget.authStore.isLoggedIn)
|
if (widget.authStore.isLoggedIn)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 5.2.9+79
|
version: 5.3.1+81
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|
|
||||||
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal file
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/playback_error_recovery.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldAutoRecoverFromPlayerError', () {
|
||||||
|
test('recovers Source error / generic failures under the refresh cap', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(refreshCapReached: false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(
|
||||||
|
refreshCapReached: false,
|
||||||
|
errorCode: null,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops once the refresh cap is reached', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(refreshCapReached: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defers mediaServicesWereReset to its dedicated rebuild path', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoRecoverFromPlayerError(
|
||||||
|
refreshCapReached: false,
|
||||||
|
errorCode: 'mediaServicesWereReset',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('playerErrorRetryDelay', () {
|
||||||
|
test('refreshes 4xx token failures immediately', () {
|
||||||
|
expect(playerErrorRetryDelay(statusCode: 403), Duration.zero);
|
||||||
|
expect(playerErrorRetryDelay(statusCode: 404), Duration.zero);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waits briefly for network / Source errors without status', () {
|
||||||
|
expect(
|
||||||
|
playerErrorRetryDelay(),
|
||||||
|
VideoTimingConstants.initRetryDelay,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
playerErrorRetryDelay(statusCode: 500),
|
||||||
|
VideoTimingConstants.initRetryDelay,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
254
test/widgets/follow_channel_controller_test.dart
Normal file
254
test/widgets/follow_channel_controller_test.dart
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/models/user.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MockAuthStore extends Mock implements AuthStore {}
|
||||||
|
|
||||||
|
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||||
|
|
||||||
|
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockAuthStore authStore;
|
||||||
|
late MockTwitchApi api;
|
||||||
|
late MockTwitchGqlApi gqlApi;
|
||||||
|
late UserStore userStore;
|
||||||
|
var notifyCount = 0;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
authStore = MockAuthStore();
|
||||||
|
api = MockTwitchApi();
|
||||||
|
gqlApi = MockTwitchGqlApi();
|
||||||
|
notifyCount = 0;
|
||||||
|
|
||||||
|
when(() => api.getUserInfo()).thenAnswer(
|
||||||
|
(_) async => const UserTwitch(
|
||||||
|
'12345',
|
||||||
|
'testuser',
|
||||||
|
'TestUser',
|
||||||
|
'https://cdn/profile.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||||
|
(_) async => <UserBlockedTwitch>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
userStore = UserStore(
|
||||||
|
twitchApi: api,
|
||||||
|
twitchGqlApi: gqlApi,
|
||||||
|
hasModeratorScopes: () => false,
|
||||||
|
);
|
||||||
|
await userStore.init();
|
||||||
|
|
||||||
|
when(() => authStore.user).thenReturn(userStore);
|
||||||
|
when(() => authStore.isLoggedIn).thenReturn(true);
|
||||||
|
when(() => authStore.gqlToken).thenReturn('web-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
FollowChannelController makeController({
|
||||||
|
String userId = '999',
|
||||||
|
bool? initialIsFollowing,
|
||||||
|
}) {
|
||||||
|
return FollowChannelController(
|
||||||
|
authStore: authStore,
|
||||||
|
userId: userId,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onChanged: () => notifyCount++,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('FollowChannelController', () {
|
||||||
|
test('hides when logged out', () {
|
||||||
|
when(() => authStore.isLoggedIn).thenReturn(false);
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.canFollow, isFalse);
|
||||||
|
expect(controller.shouldShow, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides for own channel', () {
|
||||||
|
final controller = makeController(userId: '12345');
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.isSelf, isTrue);
|
||||||
|
expect(controller.canFollow, isFalse);
|
||||||
|
expect(controller.shouldShow, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses initialIsFollowing without Helix lookup', () {
|
||||||
|
final controller = makeController(initialIsFollowing: true);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
expect(controller.shouldShow, isTrue);
|
||||||
|
verifyNever(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: any(named: 'userId'),
|
||||||
|
broadcasterId: any(named: 'broadcasterId'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads follow state from Helix and caches', () async {
|
||||||
|
when(
|
||||||
|
() => api.isFollowingChannel(
|
||||||
|
userId: '12345',
|
||||||
|
broadcasterId: '999',
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
|
final controller = makeController();
|
||||||
|
controller.init();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
expect(controller.loading, isFalse);
|
||||||
|
expect(controller.shouldShow, isTrue);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle follows then unfollows', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => gqlApi.unfollowUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
expect(await controller.toggle(), isTrue);
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isTrue);
|
||||||
|
|
||||||
|
expect(await controller.toggle(), isFalse);
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle throws when web session missing', () async {
|
||||||
|
when(() => authStore.gqlToken).thenReturn(null);
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.toggle(),
|
||||||
|
throwsA(isA<FollowNeedsWebSessionException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle rethrows integrity and clears action flag', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenThrow(const IntegrityCheckException('failed integrity check'));
|
||||||
|
when(
|
||||||
|
() => gqlApi.fetchIntegrityToken(
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenThrow(const IntegrityCheckException('bad bot'));
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.toggle(),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
expect(controller.actionInProgress, isFalse);
|
||||||
|
expect(controller.isFollowing, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryAfterIntegrity applies credentials and follows', () async {
|
||||||
|
when(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: any(named: 'targetId'),
|
||||||
|
authToken: any(named: 'authToken'),
|
||||||
|
integrityToken: any(named: 'integrityToken'),
|
||||||
|
deviceId: any(named: 'deviceId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
final okToken =
|
||||||
|
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||||
|
'is_bad_bot': 'false',
|
||||||
|
'device_id': 'device-abc',
|
||||||
|
})))}.sig';
|
||||||
|
|
||||||
|
final nowFollowing = await controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: false,
|
||||||
|
credentials: GqlIntegrityCredentials(
|
||||||
|
integrityToken: okToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nowFollowing, isTrue);
|
||||||
|
expect(controller.isFollowing, isTrue);
|
||||||
|
verify(
|
||||||
|
() => gqlApi.followUser(
|
||||||
|
targetId: '999',
|
||||||
|
authToken: 'web-token',
|
||||||
|
integrityToken: okToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryAfterIntegrity rejects bad_bot integrity', () async {
|
||||||
|
final controller = makeController(initialIsFollowing: false);
|
||||||
|
controller.init();
|
||||||
|
|
||||||
|
final badToken =
|
||||||
|
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'device_id': 'device-abc',
|
||||||
|
})))}.sig';
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: false,
|
||||||
|
credentials: GqlIntegrityCredentials(
|
||||||
|
integrityToken: badToken,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
expect(controller.actionInProgress, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue