add VOD browsing with synchronized chat replay

Implements VOD (past broadcast) browsing and chat replay that reuses
the existing live chat rendering pipeline in read-only mode.

Models & API:
- video.dart: Helix video model with duration parsing (3h26m15s → Duration)
- gql_chat_comment.dart: GQL VOD chat comment models (manual parsing)
- IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format
  so the existing chat rendering pipeline (generateSpan, badges, emotes)
  works unchanged for VOD chat
- TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination
- TwitchGqlApi.getVideoComments(): GQL persisted query using mobile
  client ID (bypasses Client-Integrity) for VOD chat comments

Architecture:
- ChatRenderContext interface: minimal abstraction over ChatStore so
  ChatMessage widget works with both live ChatStore and VodChatStore
- ChatStore now implements ChatRenderContext (one-line change)
- ChatMessage accepts ChatRenderContext instead of ChatStore, with
  type guards for live-only actions (reply, paste, name tap navigation)

VOD stores:
- VodChatStore: MobX store implementing ChatRenderContext with identical
  message batching (200ms timer, 5000 limit, 100 render limit, 20% trim),
  scroll/autoscroll, playback position tracking, seek detection
  (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with
  pre-fetching when < 50 comments remain
- VodListStore: standard paginated list store for VOD browsing

VOD UI:
- VodPlayer: matches live channel layout exactly (no AppBar, SafeArea,
  16:9 WebView + expanded chat) with JS position polling every 1s +
  seeked event listener via VodPosition JS channel
- VodList: paginated VOD list with scroll-based loading
- VodCard: thumbnail card with duration overlay and relative dates
- Entry point: "Past broadcasts" in chat details drawer

Tests (21 new):
- video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing
- gql_chat_comment_test.dart: comment parsing, response error handling,
  IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user)
- twitch_gql_api_test.dart: getVideoComments mock tests
- twitch_api_test.dart: getVideos mock tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-09 01:37:02 -07:00
parent 6695c13455
commit e73d9b255f
23 changed files with 2279 additions and 57 deletions

View file

@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/shared_chat_session.dart';
import 'package:frosty/models/stream.dart'; import 'package:frosty/models/stream.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/models/video.dart';
/// The Twitch service for making API calls. /// The Twitch service for making API calls.
class TwitchApi extends BaseApiClient { class TwitchApi extends BaseApiClient {
@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient {
return FollowedChannels.fromJson(data); return FollowedChannels.fromJson(data);
} }
/// Returns a [VideosTwitch] object containing past broadcasts for the given [userId].
Future<VideosTwitch> getVideos({
required String userId,
String? cursor,
}) async {
final queryParams = <String, String>{
'user_id': userId,
'first': '20',
'type': 'archive',
};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>('/videos', queryParameters: queryParams);
return VideosTwitch.fromJson(data);
}
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
Future<StreamsTwitch> getStreamsUnderCategory({ Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId, required String gameId,

View file

@ -3,12 +3,13 @@ import 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/playback_access_token.dart';
/// Twitch GQL API client for fetching playback access tokens. /// Twitch GQL API client for playback access tokens and VOD chat.
/// ///
/// Uses the Twitch web client ID (not the app's registered client ID) /// Uses the Twitch web client ID for playback tokens and the mobile
/// since the GQL endpoint requires it. /// client ID for VOD chat (bypasses Client-Integrity checks).
class TwitchGqlApi extends BaseApiClient { class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
@ -66,4 +67,47 @@ class TwitchGqlApi extends BaseApiClient {
'&playlist_include_framerate=true' '&playlist_include_framerate=true'
'&p=$random'; '&p=$random';
} }
/// Fetches VOD chat comments for the given [videoId].
///
/// Pass [contentOffsetSeconds] for initial fetch at a position, or
/// [cursor] for pagination from a previous response.
Future<GqlChatResponse> getVideoComments({
required String videoId,
int? contentOffsetSeconds,
String? cursor,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlMobileClientId,
};
final variables = <String, dynamic>{
'videoID': videoId,
};
if (cursor != null) {
variables['cursor'] = cursor;
} else {
variables['contentOffsetSeconds'] = contentOffsetSeconds ?? 0;
}
final body = {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
};
final response = await post<JsonMap>(
'/gql',
data: body,
headers: headers,
);
return GqlChatResponse.fromJson(response);
}
} }

View file

@ -9,6 +9,9 @@ const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the last-selected stream quality. /// SharedPreferences key for the last-selected stream quality.
const kLastStreamQualityKey = 'last_stream_quality'; const kLastStreamQualityKey = 'last_stream_quality';
/// Twitch GQL mobile client ID (bypasses Client-Integrity checks, used for VOD chat).
const twitchGqlMobileClientId = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp';
/// Twitch API client secret. /// Twitch API client secret.
const secret = String.fromEnvironment('SECRET'); const secret = String.fromEnvironment('SECRET');

View file

@ -0,0 +1,137 @@
// Models for Twitch GQL VOD chat comments.
//
// Manually parsed (no codegen) because the GQL response uses camelCase
// and deeply nested edges/node patterns.
class GqlChatEmote {
final String emoteId;
const GqlChatEmote({required this.emoteId});
factory GqlChatEmote.fromJson(Map<String, dynamic> json) {
return GqlChatEmote(emoteId: json['emoteID'] as String);
}
}
class GqlChatFragment {
final String? text;
final GqlChatEmote? emote;
const GqlChatFragment({this.text, this.emote});
factory GqlChatFragment.fromJson(Map<String, dynamic> json) {
return GqlChatFragment(
text: json['text'] as String?,
emote: json['emote'] != null
? GqlChatEmote.fromJson(json['emote'] as Map<String, dynamic>)
: null,
);
}
}
class GqlChatBadge {
final String setId;
final String version;
const GqlChatBadge({required this.setId, required this.version});
factory GqlChatBadge.fromJson(Map<String, dynamic> json) {
return GqlChatBadge(
setId: json['setID'] as String,
version: json['version'] as String,
);
}
}
class GqlChatComment {
final String id;
final int contentOffsetSeconds;
final String? commenterLogin;
final String? commenterDisplayName;
final String? commenterId;
final String? userColor;
final List<GqlChatBadge> userBadges;
final List<GqlChatFragment> messageFragments;
const GqlChatComment({
required this.id,
required this.contentOffsetSeconds,
this.commenterLogin,
this.commenterDisplayName,
this.commenterId,
this.userColor,
required this.userBadges,
required this.messageFragments,
});
factory GqlChatComment.fromGqlNode(Map<String, dynamic> node) {
final commenter = node['commenter'] as Map<String, dynamic>?;
final message = node['message'] as Map<String, dynamic>;
return GqlChatComment(
id: node['id'] as String,
contentOffsetSeconds: node['contentOffsetSeconds'] as int,
commenterLogin: commenter?['login'] as String?,
commenterDisplayName: commenter?['displayName'] as String?,
commenterId: commenter?['id'] as String?,
userColor: message['userColor'] as String?,
userBadges: (message['userBadges'] as List)
.map((b) => GqlChatBadge.fromJson(b as Map<String, dynamic>))
.toList(),
messageFragments: (message['fragments'] as List)
.map((f) => GqlChatFragment.fromJson(f as Map<String, dynamic>))
.toList(),
);
}
}
class GqlChatResponse {
final List<GqlChatComment> comments;
final bool hasNextPage;
final String? cursor;
const GqlChatResponse({
required this.comments,
required this.hasNextPage,
this.cursor,
});
factory GqlChatResponse.fromJson(Map<String, dynamic> json) {
// GQL returns 200 even on errors — check for errors array.
if (json['errors'] != null) {
final errors = json['errors'] as List;
final message = errors.isNotEmpty
? (errors.first as Map<String, dynamic>)['message'] as String?
: 'Unknown GQL error';
throw Exception('GQL error: $message');
}
final video = json['data']?['video'] as Map<String, dynamic>?;
if (video == null) {
return const GqlChatResponse(
comments: [],
hasNextPage: false,
);
}
final commentsData = video['comments'] as Map<String, dynamic>;
final edges = commentsData['edges'] as List;
final pageInfo = commentsData['pageInfo'] as Map<String, dynamic>;
final comments = edges
.map((edge) => GqlChatComment.fromGqlNode(
(edge as Map<String, dynamic>)['node'] as Map<String, dynamic>,
))
.toList();
final cursor = edges.isNotEmpty
? (edges.last as Map<String, dynamic>)['cursor'] as String?
: null;
return GqlChatResponse(
comments: comments,
hasNextPage: pageInfo['hasNextPage'] as bool,
cursor: cursor,
);
}
}

View file

@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart'; import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -1155,6 +1156,56 @@ class IRCMessage {
); );
} }
/// Creates an [IRCMessage] from a GQL VOD chat comment.
///
/// Maps GQL fields to IRC tags so [generateSpan] works identically
/// for both live and VOD chat messages.
factory IRCMessage.fromGqlComment(GqlChatComment comment) {
final tags = <String, String>{
'display-name': comment.commenterDisplayName ?? 'Deleted User',
'user-id': comment.commenterId ?? '',
'id': comment.id,
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
if (comment.userColor != null) 'color': comment.userColor!,
if (comment.userBadges.isNotEmpty)
'badges': comment.userBadges
.map((b) => '${b.setId}/${b.version}')
.join(','),
};
// Reconstruct message text from fragments.
final messageText = comment.messageFragments
.map((f) => f.text ?? '')
.join();
// Build local emotes map from fragments that have Twitch emotes.
// generateSpan() will match these by name in the split words.
final localEmotes = <String, Emote>{};
for (final fragment in comment.messageFragments) {
if (fragment.emote != null && fragment.text != null) {
localEmotes[fragment.text!] = Emote(
name: fragment.text!,
zeroWidth: false,
url:
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
type: EmoteType.twitchSub,
);
}
}
return IRCMessage(
raw: '',
command: Command.privateMessage,
tags: tags,
user: comment.commenterLogin,
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
message: messageText,
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
action: false,
mention: false,
);
}
factory IRCMessage.createNotice({ factory IRCMessage.createNotice({
required String message, required String message,
VoidCallback? actionCallback, VoidCallback? actionCallback,

60
lib/models/video.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:json_annotation/json_annotation.dart';
part 'video.g.dart';
/// Object for Twitch VODs (past broadcasts).
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideoTwitch {
final String id;
final String? streamId;
final String userId;
final String userLogin;
final String userName;
final String title;
final String createdAt;
final String publishedAt;
final String duration;
final String thumbnailUrl;
final int viewCount;
const VideoTwitch(
this.id,
this.streamId,
this.userId,
this.userLogin,
this.userName,
this.title,
this.createdAt,
this.publishedAt,
this.duration,
this.thumbnailUrl,
this.viewCount,
);
factory VideoTwitch.fromJson(Map<String, dynamic> json) =>
_$VideoTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideosTwitch {
final List<VideoTwitch> data;
final Map<String, String> pagination;
const VideosTwitch(this.data, this.pagination);
factory VideosTwitch.fromJson(Map<String, dynamic> json) =>
_$VideosTwitchFromJson(json);
}
/// Parses a Twitch duration string like "3h26m15s" into a [Duration].
Duration parseTwitchDuration(String duration) {
final regex = RegExp(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?');
final match = regex.firstMatch(duration);
if (match == null) return Duration.zero;
final hours = int.tryParse(match.group(1) ?? '') ?? 0;
final minutes = int.tryParse(match.group(2) ?? '') ?? 0;
final seconds = int.tryParse(match.group(3) ?? '') ?? 0;
return Duration(hours: hours, minutes: minutes, seconds: seconds);
}

28
lib/models/video.g.dart Normal file
View file

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'video.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VideoTwitch _$VideoTwitchFromJson(Map<String, dynamic> json) => VideoTwitch(
json['id'] as String,
json['stream_id'] as String?,
json['user_id'] as String,
json['user_login'] as String,
json['user_name'] as String,
json['title'] as String,
json['created_at'] as String,
json['published_at'] as String,
json['duration'] as String,
json['thumbnail_url'] as String,
(json['view_count'] as num).toInt(),
);
VideosTwitch _$VideosTwitchFromJson(Map<String, dynamic> json) => VideosTwitch(
(json['data'] as List<dynamic>)
.map((e) => VideoTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
Map<String, String>.from(json['pagination'] as Map),
);

View file

@ -0,0 +1,18 @@
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
/// Minimal interface for rendering chat messages.
///
/// Implemented by both [ChatStore] (live chat) and [VodChatStore] (VOD replay)
/// so that [ChatMessage] can render messages from either source.
abstract class ChatRenderContext {
ChatAssetsStore get assetsStore;
AuthStore get auth;
SettingsStore get settings;
String get channelId;
ObservableSet<String> get revealedMessageIds;
void revealMessage(String id);
void updateNotification(String message);
}

View file

@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.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/vod/vod_list.dart';
import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/utils.dart'; import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -371,6 +372,20 @@ class _ChatDetailsState extends State<ChatDetails> {
), ),
), ),
), ),
ListTile(
leading: const Icon(Icons.video_library_rounded),
title: const Text('Past broadcasts'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodList(
userId: widget.chatStore.channelId,
displayName: widget.chatStore.displayName,
),
),
),
),
ListTile( ListTile(
leading: const Icon(Icons.add_comment_rounded), leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'), title: const Text('Add chat'),

View file

@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/events.dart'; import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -22,7 +23,7 @@ part 'chat_store.g.dart';
/// The store and view-model for chat-related activities. /// The store and view-model for chat-related activities.
class ChatStore = ChatStoreBase with _$ChatStore; class ChatStore = ChatStoreBase with _$ChatStore;
abstract class ChatStoreBase with Store { abstract class ChatStoreBase with Store implements ChatRenderContext {
/// The total maximum amount of messages in chat. /// The total maximum amount of messages in chat.
static const _messageLimit = 5000; static const _messageLimit = 5000;
@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store {
final _messagesToRemove = (_messageLimit * 0.2).toInt(); final _messagesToRemove = (_messageLimit * 0.2).toInt();
/// The provided auth store to determine login status, get the token, and use the headers for requests. /// The provided auth store to determine login status, get the token, and use the headers for requests.
@override
final AuthStore auth; final AuthStore auth;
/// The provided setting store to account for any user-defined behaviors. /// The provided setting store to account for any user-defined behaviors.
@override
final SettingsStore settings; final SettingsStore settings;
/// The focus node for the textfield to allow for showing and hiding the keyboard/focus. /// The focus node for the textfield to allow for showing and hiding the keyboard/focus.
@ -111,6 +114,7 @@ abstract class ChatStoreBase with Store {
final String channelName; final String channelName;
/// The channel's ID for API requests. /// The channel's ID for API requests.
@override
final String channelId; final String channelId;
/// The channel's display name to show on widgets. /// The channel's display name to show on widgets.
@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store {
final ChatDetailsStore chatDetailsStore; final ChatDetailsStore chatDetailsStore;
/// The assets store responsible for badges, emotes, and the emote menu. /// The assets store responsible for badges, emotes, and the emote menu.
@override
final ChatAssetsStore assetsStore; final ChatAssetsStore assetsStore;
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
@ -175,8 +180,10 @@ abstract class ChatStoreBase with Store {
final _pendingSevenTVCallbacks = <_DelayedCallback>[]; final _pendingSevenTVCallbacks = <_DelayedCallback>[];
/// The set of message IDs that have been revealed by the user (for deleted messages). /// The set of message IDs that have been revealed by the user (for deleted messages).
@override
final revealedMessageIds = ObservableSet<String>(); final revealedMessageIds = ObservableSet<String>();
@override
@action @action
void revealMessage(String id) { void revealMessage(String id) {
revealedMessageIds.add(id); revealedMessageIds.add(id);
@ -1269,6 +1276,7 @@ abstract class ChatStoreBase with Store {
} }
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage]. /// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
@override
@action @action
void updateNotification(String notificationMessage) { void updateNotification(String notificationMessage) {
// Cancel the previous notification to prevent the notification from phasing in and out // Cancel the previous notification to prevent the notification from phasing in and out

View file

@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.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/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
@ -15,7 +16,7 @@ import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget { class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage; final IRCMessage ircMessage;
final ChatStore chatStore; final ChatRenderContext chatStore;
final bool isModal; final bool isModal;
final bool showReplyHeader; final bool showReplyHeader;
final bool isInReplyThread; final bool isInReplyThread;
@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet. // Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return; if (isModal) return;
// User modal requires live ChatStore (recent messages, blocking, etc.).
if (chatStore is! ChatStore) return;
// Ignore if long-pressing own username. // Ignore if long-pressing own username.
if (ircMessage.user == null || if (ircMessage.user == null ||
ircMessage.user == chatStore.auth.user.details?.login) { ircMessage.user == chatStore.auth.user.details?.login) {
@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => ChatUserModal( builder: (context) => ChatUserModal(
chatStore: chatStore, chatStore: chatStore as ChatStore,
username: ircMessage.user!, username: ircMessage.user!,
userId: ircMessage.tags['user-id']!, userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!, displayName: ircMessage.tags['display-name']!,
@ -73,6 +77,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet. // Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return; if (isModal) return;
// User modal requires live ChatStore.
if (chatStore is! ChatStore) return;
final twitchApi = context.read<TwitchApi>(); final twitchApi = context.read<TwitchApi>();
twitchApi.getUser(userLogin: nickname).then((user) { twitchApi.getUser(userLogin: nickname).then((user) {
if (context.mounted) { if (context.mounted) {
@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => ChatUserModal( builder: (context) => ChatUserModal(
chatStore: chatStore, chatStore: chatStore as ChatStore,
username: user.login, username: user.login,
userId: user.id, userId: user.id,
displayName: user.displayName, displayName: user.displayName,
@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget {
} }
/// The store to use for notifications and emote menu in merged mode. /// The store to use for notifications and emote menu in merged mode.
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore; ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore;
Future<void> copyMessage() async { Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy message'), title: const Text('Copy message'),
), ),
ListTile( if (chatStore is ChatStore) ...[
onTap: () async { ListTile(
await copyMessage(); onTap: () async {
await copyMessage();
final liveChatStore = chatStore as ChatStore;
final effectiveChatDelay = chatStore.settings.effectiveChatDelay; final effectiveChatDelay =
liveChatStore.settings.effectiveChatDelay;
final hasChatDelay = final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0; liveChatStore.settings.showVideo && effectiveChatDelay > 0;
if (hasChatDelay) { if (hasChatDelay) {
_effectiveInputStore.updateNotification( _effectiveInputStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
); );
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// In merged mode, switch to the source tab so the paste
// targets the correct channel's composer.
onActivateSourceTab?.call();
// Paste the copied message into the text controller
liveChatStore.textController.text = ircMessage.message ?? '';
liveChatStore.safeRequestFocus();
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
} }
return; },
} leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
final liveChatStore = chatStore as ChatStore;
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
// In merged mode, switch to the source tab so the paste liveChatStore.replyingToMessage = ircMessage;
// targets the correct channel's composer. liveChatStore.safeRequestFocus();
onActivateSourceTab?.call();
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
} },
}, leading: const Icon(Icons.reply),
leading: const Icon(Icons.content_paste), title: const Text('Reply to message'),
title: const Text('Copy message and paste'), ),
), ],
ListTile(
onTap: () {
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
chatStore.replyingToMessage = ircMessage;
chatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
], ],
), ),
); );
@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget {
color: messageHeaderTextColor, color: messageHeaderTextColor,
); );
messageHeader = GestureDetector( messageHeader = GestureDetector(
onTap: isModal onTap: isModal || chatStore is! ChatStore
? null ? null
: () => showModalBottomSheetWithProperFocus( : () => showModalBottomSheetWithProperFocus(
context: context, context: context,
builder: (context) { builder: (context) {
return ReplyThread( return ReplyThread(
selectedMessage: ircMessage, selectedMessage: ircMessage,
chatStore: chatStore, chatStore: chatStore as ChatStore,
); );
}, },
), ),

View file

@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
part 'vod_chat_store.g.dart';
class VodChatStore = VodChatStoreBase with _$VodChatStore;
abstract class VodChatStoreBase with Store implements ChatRenderContext {
/// The total maximum amount of messages in chat.
static const _messageLimit = 5000;
/// The maximum amount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
@override
final ChatAssetsStore assetsStore;
@override
final AuthStore auth;
@override
final SettingsStore settings;
@override
final String channelId;
@override
final revealedMessageIds = ObservableSet<String>();
final TwitchGqlApi twitchGqlApi;
final String videoId;
/// The scroll controller that controls auto-scroll and resume-scroll behavior.
final scrollController = ScrollController();
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
final _messagesToRemove = (_messageLimit * 0.2).toInt();
/// The periodic timer used for batching chat message re-renders.
Timer? _messageBufferTimer;
/// The list of chat messages to add once autoscroll is resumed.
final messageBuffer = ObservableList<IRCMessage>();
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// Fetched GQL comments waiting to be advanced based on playback position.
final _commentBuffer = Queue<GqlChatComment>();
/// Cursor for fetching the next page of comments.
String? _nextCursor;
/// Whether we're currently fetching comments from the API.
bool _isFetching = false;
/// The last rendered content offset to avoid duplicates.
int _lastRenderedOffset = -1;
/// A notification message to display above the chat.
@readonly
String? _notification;
/// The list of chat messages to render and display.
@readonly
var _messages = ObservableList<IRCMessage>();
/// If the chat should automatically scroll/jump to the latest message.
@readonly
var _autoScroll = true;
/// Current playback position in seconds.
@observable
int playbackPosition = 0;
/// Whether the store has been initialized.
@readonly
var _isInitialized = false;
/// The list of chat messages that should be rendered. Used to prevent jank when resuming scroll.
@computed
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
}
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
return _messages.sublist(_messages.length - _renderMessageLimit);
}
VodChatStoreBase({
required this.assetsStore,
required this.auth,
required this.settings,
required this.channelId,
required this.twitchGqlApi,
required this.videoId,
});
Future<void> init() async {
await Future.wait([
assetsStore.init().then((_) => _loadAssets()),
_fetchComments(contentOffsetSeconds: 0),
]);
_startMessageBufferTimer();
_setupScrollListener();
_isInitialized = true;
}
Future<void> _loadAssets() async {
await assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showBTTVBadges: settings.showBTTVBadges,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
}
void _startMessageBufferTimer() {
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => addMessages(),
);
}
void _setupScrollListener() {
scrollController.addListener(() {
if (!scrollController.hasClients) return;
final isAtBottom = scrollController.offset <= 0;
if (isAtBottom != _autoScroll) {
_autoScroll = isAtBottom;
if (_autoScroll) addMessages();
}
});
}
@action
void addMessages() {
if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(messageBuffer);
messageBuffer.clear();
if (_messages.length > _messageLimit) {
_messages.removeRange(0, _messagesToRemove);
}
}
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
_autoScroll = true;
scrollController.jumpTo(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.jumpTo(0);
});
}
@override
@action
void revealMessage(String id) {
revealedMessageIds.add(id);
}
@override
@action
void updateNotification(String message) {
_notificationTimer?.cancel();
_notification = message;
_notificationTimer = Timer(const Duration(seconds: 5), () {
_notification = null;
});
}
@action
void clearNotification() {
_notificationTimer?.cancel();
_notification = null;
}
/// Called from the JS channel with the current video position.
@action
void updatePlaybackPosition(int offsetSeconds) {
final previousPosition = playbackPosition;
playbackPosition = offsetSeconds;
// Seek detection: if position jumped significantly, re-fetch.
final delta = offsetSeconds - previousPosition;
if (delta < -5 || delta > 10) {
handleSeek(offsetSeconds);
return;
}
// Advance comments from buffer to messageBuffer.
_advanceComments(offsetSeconds);
// Pre-fetch when running low.
if (_commentBuffer.length < 50 && !_isFetching && _nextCursor != null) {
_fetchComments(cursor: _nextCursor);
}
}
void _advanceComments(int offsetSeconds) {
while (_commentBuffer.isNotEmpty &&
_commentBuffer.first.contentOffsetSeconds <= offsetSeconds) {
final comment = _commentBuffer.removeFirst();
if (comment.contentOffsetSeconds > _lastRenderedOffset ||
_lastRenderedOffset == -1) {
_lastRenderedOffset = comment.contentOffsetSeconds;
messageBuffer.add(IRCMessage.fromGqlComment(comment));
}
}
}
/// Handles an explicit seek to [offsetSeconds], clearing state and re-fetching.
@action
Future<void> handleSeek(int offsetSeconds) async {
playbackPosition = offsetSeconds;
_commentBuffer.clear();
messageBuffer.clear();
_messages.clear();
_lastRenderedOffset = -1;
_nextCursor = null;
await _fetchComments(contentOffsetSeconds: offsetSeconds);
}
Future<void> _fetchComments({
int? contentOffsetSeconds,
String? cursor,
}) async {
if (_isFetching) return;
_isFetching = true;
try {
final response = await twitchGqlApi.getVideoComments(
videoId: videoId,
contentOffsetSeconds: contentOffsetSeconds,
cursor: cursor,
);
_commentBuffer.addAll(response.comments);
if (response.hasNextPage) {
_nextCursor = response.cursor;
} else {
_nextCursor = null;
}
} catch (e) {
debugPrint('Failed to fetch VOD comments: $e');
} finally {
_isFetching = false;
}
}
void dispose() {
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
scrollController.dispose();
}
}

View file

@ -0,0 +1,203 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_chat_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VodChatStore on VodChatStoreBase, Store {
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
List<IRCMessage> get renderMessages =>
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
() => super.renderMessages,
name: 'VodChatStoreBase.renderMessages',
)).value;
late final _$_notificationAtom = Atom(
name: 'VodChatStoreBase._notification',
context: context,
);
String? get notification {
_$_notificationAtom.reportRead();
return super._notification;
}
@override
String? get _notification => notification;
@override
set _notification(String? value) {
_$_notificationAtom.reportWrite(value, super._notification, () {
super._notification = value;
});
}
late final _$_messagesAtom = Atom(
name: 'VodChatStoreBase._messages',
context: context,
);
ObservableList<IRCMessage> get messages {
_$_messagesAtom.reportRead();
return super._messages;
}
@override
ObservableList<IRCMessage> get _messages => messages;
@override
set _messages(ObservableList<IRCMessage> value) {
_$_messagesAtom.reportWrite(value, super._messages, () {
super._messages = value;
});
}
late final _$_autoScrollAtom = Atom(
name: 'VodChatStoreBase._autoScroll',
context: context,
);
bool get autoScroll {
_$_autoScrollAtom.reportRead();
return super._autoScroll;
}
@override
bool get _autoScroll => autoScroll;
@override
set _autoScroll(bool value) {
_$_autoScrollAtom.reportWrite(value, super._autoScroll, () {
super._autoScroll = value;
});
}
late final _$playbackPositionAtom = Atom(
name: 'VodChatStoreBase.playbackPosition',
context: context,
);
@override
int get playbackPosition {
_$playbackPositionAtom.reportRead();
return super.playbackPosition;
}
@override
set playbackPosition(int value) {
_$playbackPositionAtom.reportWrite(value, super.playbackPosition, () {
super.playbackPosition = value;
});
}
late final _$_isInitializedAtom = Atom(
name: 'VodChatStoreBase._isInitialized',
context: context,
);
bool get isInitialized {
_$_isInitializedAtom.reportRead();
return super._isInitialized;
}
@override
bool get _isInitialized => isInitialized;
@override
set _isInitialized(bool value) {
_$_isInitializedAtom.reportWrite(value, super._isInitialized, () {
super._isInitialized = value;
});
}
late final _$VodChatStoreBaseActionController = ActionController(
name: 'VodChatStoreBase',
context: context,
);
@override
void addMessages() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.addMessages',
);
try {
return super.addMessages();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void resumeScroll() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.resumeScroll',
);
try {
return super.resumeScroll();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void revealMessage(String id) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.revealMessage',
);
try {
return super.revealMessage(id);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updateNotification(String message) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updateNotification',
);
try {
return super.updateNotification(message);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void clearNotification() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.clearNotification',
);
try {
return super.clearNotification();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updatePlaybackPosition(int offsetSeconds) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updatePlaybackPosition',
);
try {
return super.updatePlaybackPosition(offsetSeconds);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
playbackPosition: ${playbackPosition},
renderMessages: ${renderMessages}
''';
}
}

View file

@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/video.dart';
import 'package:mobx/mobx.dart';
part 'vod_list_store.g.dart';
class VodListStore = VodListStoreBase with _$VodListStore;
abstract class VodListStoreBase with Store {
final TwitchApi twitchApi;
final String userId;
@readonly
var _videos = ObservableList<VideoTwitch>();
@readonly
var _isLoading = false;
@readonly
var _error = '';
String? _cursor;
bool _hasMore = true;
VodListStoreBase({
required this.twitchApi,
required this.userId,
});
@action
Future<void> fetchVideos() async {
_isLoading = true;
_error = '';
try {
final response = await twitchApi.getVideos(userId: userId);
_videos = response.data.asObservable();
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
_error = e.toString();
debugPrint('Failed to fetch VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> fetchMore() async {
if (_isLoading || !_hasMore || _cursor == null) return;
_isLoading = true;
try {
final response = await twitchApi.getVideos(
userId: userId,
cursor: _cursor,
);
_videos.addAll(response.data);
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
debugPrint('Failed to fetch more VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> refresh() async {
_cursor = null;
_hasMore = true;
await fetchVideos();
}
}

View file

@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_list_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VodListStore on VodListStoreBase, Store {
late final _$_videosAtom = Atom(
name: 'VodListStoreBase._videos',
context: context,
);
ObservableList<VideoTwitch> get videos {
_$_videosAtom.reportRead();
return super._videos;
}
@override
ObservableList<VideoTwitch> get _videos => videos;
@override
set _videos(ObservableList<VideoTwitch> value) {
_$_videosAtom.reportWrite(value, super._videos, () {
super._videos = value;
});
}
late final _$_isLoadingAtom = Atom(
name: 'VodListStoreBase._isLoading',
context: context,
);
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
late final _$_errorAtom = Atom(
name: 'VodListStoreBase._error',
context: context,
);
String get error {
_$_errorAtom.reportRead();
return super._error;
}
@override
String get _error => error;
@override
set _error(String value) {
_$_errorAtom.reportWrite(value, super._error, () {
super._error = value;
});
}
late final _$fetchVideosAsyncAction = AsyncAction(
'VodListStoreBase.fetchVideos',
context: context,
);
@override
Future<void> fetchVideos() {
return _$fetchVideosAsyncAction.run(() => super.fetchVideos());
}
late final _$fetchMoreAsyncAction = AsyncAction(
'VodListStoreBase.fetchMore',
context: context,
);
@override
Future<void> fetchMore() {
return _$fetchMoreAsyncAction.run(() => super.fetchMore());
}
late final _$refreshAsyncAction = AsyncAction(
'VodListStoreBase.refresh',
context: context,
);
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,184 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/video.dart';
import 'package:frosty/screens/channel/vod/vod_player.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
/// A tappable card widget that displays a VOD's thumbnail and details.
class VodCard extends StatelessWidget {
final VideoTwitch video;
const VodCard({super.key, required this.video});
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h${minutes.toString().padLeft(2, '0')}m${seconds.toString().padLeft(2, '0')}s';
}
return '${minutes}m${seconds.toString().padLeft(2, '0')}s';
}
String _formatRelativeDate(String createdAt) {
final date = DateTime.tryParse(createdAt);
if (date == null) return createdAt;
final now = DateTime.now().toUtc();
final difference = now.difference(date);
if (difference.inDays >= 365) {
final years = difference.inDays ~/ 365;
return '$years ${years == 1 ? 'year' : 'years'} ago';
} else if (difference.inDays >= 30) {
final months = difference.inDays ~/ 30;
return '$months ${months == 1 ? 'month' : 'months'} ago';
} else if (difference.inDays >= 1) {
return '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago';
} else {
return '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago';
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final duration = parseTwitchDuration(video.duration);
final thumbnailUrl = video.thumbnailUrl
.replaceFirst('%{width}', '$thumbnailWidth')
.replaceFirst('%{height}', '$thumbnailHeight');
final thumbnail = AspectRatio(
aspectRatio: 16 / 9,
child: thumbnailUrl.isEmpty
? Container(color: Colors.black12)
: FrostyCachedNetworkImage(
imageUrl: thumbnailUrl,
placeholder: (context, url) => const SkeletonLoader(
borderRadius: kCardBorderRadius,
),
useOldImageOnUrlChange: true,
),
);
final fontColor = DefaultTextStyle.of(context).style.color;
const subFontSize = 14.0;
final imageSection = ClipRRect(
borderRadius: kCardBorderRadius,
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
margin: const EdgeInsets.all(3),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
width: 0.5,
),
),
child: Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.frostyColors.overlayOnSurface,
),
),
),
],
),
);
final vodInfoSection = Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Tooltip(
message: video.title.trim(),
preferBelow: false,
child: Text(
video.title.trim(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: fontColor,
),
),
),
Text(
_formatRelativeDate(video.createdAt),
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
),
),
Text(
'${_formatViewCount(video.viewCount)} views',
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodPlayer(
videoId: video.id,
channelId: video.userId,
title: video.title,
),
),
),
child: Padding(
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: 16 + MediaQuery.of(context).padding.left,
right: 16 + MediaQuery.of(context).padding.right,
),
child: Row(
children: [
Flexible(child: imageSection),
Flexible(flex: 2, child: vodInfoSection),
],
),
),
);
}
String _formatViewCount(int count) {
if (count >= 1000000) {
return '${(count / 1000000).toStringAsFixed(1)}M';
} else if (count >= 1000) {
return '${(count / 1000).toStringAsFixed(1)}K';
}
return count.toString();
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/vod/stores/vod_list_store.dart';
import 'package:frosty/screens/channel/vod/vod_card.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart';
class VodList extends StatefulWidget {
final String userId;
final String displayName;
const VodList({
super.key,
required this.userId,
required this.displayName,
});
@override
State<VodList> createState() => _VodListState();
}
class _VodListState extends State<VodList> {
late final VodListStore _store = VodListStore(
twitchApi: context.twitchApi,
userId: widget.userId,
);
@override
void initState() {
super.initState();
_store.fetchVideos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('${widget.displayName} — Past broadcasts')),
body: Observer(
builder: (context) {
if (_store.error.isNotEmpty && _store.videos.isEmpty) {
return Center(
child: AlertMessage(message: _store.error),
);
}
if (_store.videos.isEmpty && !_store.isLoading) {
return const Center(
child: AlertMessage(message: 'No past broadcasts found'),
);
}
return RefreshIndicator.adaptive(
onRefresh: _store.refresh,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
notification.metrics.extentAfter < 200) {
_store.fetchMore();
}
return false;
},
child: ListView.builder(
itemCount: _store.videos.length + (_store.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _store.videos.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator.adaptive(),
),
);
}
return VodCard(video: _store.videos[index]);
},
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,282 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/vod/stores/vod_chat_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
/// VOD player + chat replay screen.
///
/// Layout matches the live channel view [VideoChat]: no AppBar,
/// video on top (16:9), chat below (expanded).
class VodPlayer extends StatefulWidget {
final String videoId;
final String channelId;
final String title;
const VodPlayer({
super.key,
required this.videoId,
required this.channelId,
required this.title,
});
@override
State<VodPlayer> createState() => _VodPlayerState();
}
class _VodPlayerState extends State<VodPlayer> {
late final VodChatStore _vodChatStore;
late final WebViewController _webViewController;
String get _videoUrl =>
'https://player.twitch.tv/?video=v${widget.videoId}&parent=frosty&muted=false';
@override
void initState() {
super.initState();
_vodChatStore = VodChatStore(
assetsStore: ChatAssetsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
globalAssetsStore: context.globalAssetsStore,
),
auth: context.authStore,
settings: context.settingsStore,
channelId: widget.channelId,
twitchGqlApi: context.twitchGqlApi,
videoId: widget.videoId,
);
_vodChatStore.init();
_initWebView();
}
void _initWebView() {
// Platform-specific params for autoplay.
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
_webViewController =
WebViewController.fromPlatformCreationParams(params)
..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'VodPosition',
onMessageReceived: (message) {
final text = message.message;
if (text.startsWith('seek:')) {
final seconds =
double.tryParse(text.substring(5))?.toInt() ?? 0;
_vodChatStore.handleSeek(seconds);
} else {
final seconds = double.tryParse(text)?.toInt() ?? 0;
_vodChatStore.updatePlaybackPosition(seconds);
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (!url.contains('player.twitch.tv')) return;
await _injectPositionTracker();
},
),
);
// Android: disable user gesture requirement for autoplay.
if (_webViewController.platform is AndroidWebViewController) {
(_webViewController.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
_webViewController.loadRequest(Uri.parse(_videoUrl));
}
Future<void> _injectPositionTracker() async {
// Wait for the video element to be available, then poll currentTime
// and listen for seek events.
await _webViewController.runJavaScript('''
(function() {
function tryAttach() {
var video = document.querySelector('video');
if (!video) {
setTimeout(tryAttach, 500);
return;
}
setInterval(function() {
VodPosition.postMessage(Math.floor(video.currentTime).toString());
}, 1000);
video.addEventListener('seeked', function() {
VodPosition.postMessage('seek:' + Math.floor(video.currentTime).toString());
});
}
tryAttach();
})();
''');
}
Widget _buildWebView() {
if (Platform.isAndroid &&
WebViewPlatform.instance is AndroidWebViewPlatform) {
return WebViewWidget.fromPlatformCreationParams(
params: AndroidWebViewWidgetCreationParams(
controller: _webViewController.platform,
displayWithHybridComposition:
!context.settingsStore.useTextureRendering,
),
);
}
return WebViewWidget(controller: _webViewController);
}
Widget _buildChatList() {
return Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: _vodChatStore.settings.messageScale.textScaler,
),
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
fontSize: _vodChatStore.settings.fontSize,
),
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
FrostyScrollbar(
controller: _vodChatStore.scrollController,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (context) {
final messages = _vodChatStore.renderMessages;
return ListView.builder(
reverse: true,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
addAutomaticKeepAlives: false,
controller: _vodChatStore.scrollController,
itemCount: messages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage:
messages[messages.length - 1 - index],
chatStore: _vodChatStore,
),
);
},
),
),
_buildResumeScrollButton(),
],
),
),
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (_) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: _vodChatStore.resumeScroll,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
_vodChatStore.messageBuffer.isNotEmpty
? '${_vodChatStore.messageBuffer.length} new ${_vodChatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: _buildWebView(),
),
Expanded(
child: Stack(
children: [
_buildChatList(),
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.notification != null
? Align(
alignment: Alignment.topCenter,
child: FrostyNotification(
message: _vodChatStore.notification!,
onDismissed: _vodChatStore.clearNotification,
),
)
: null,
),
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_vodChatStore.dispose();
super.dispose();
}
}

View file

@ -190,10 +190,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@ -797,18 +797,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -1267,10 +1267,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View file

@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/video.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../fixtures/api_responses.dart'; import '../fixtures/api_responses.dart';
@ -389,4 +390,63 @@ void main() {
expect(emotes[2].type, EmoteType.twitchChannel); expect(emotes[2].type, EmoteType.twitchChannel);
}); });
}); });
group('getVideos', () {
test('returns videos for a user', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/videos',
(server) => server.reply(200, {
'data': [
{
'id': '12345',
'stream_id': '67890',
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test VOD',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '3h26m15s',
'thumbnail_url': 'https://cdn/thumb.jpg',
'view_count': 500,
},
],
'pagination': {'cursor': 'next_page'},
}),
queryParameters: {
'user_id': '111',
'first': '20',
'type': 'archive',
},
);
final videos = await api.getVideos(userId: '111');
expect(videos.data, hasLength(1));
expect(videos.data.first.id, '12345');
expect(videos.data.first.title, 'Test VOD');
expect(videos.data.first.duration, '3h26m15s');
expect(videos.pagination['cursor'], 'next_page');
});
test('passes cursor for pagination', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/videos',
(server) => server.reply(200, {
'data': <Map<String, dynamic>>[],
'pagination': <String, String>{},
}),
queryParameters: {
'user_id': '111',
'first': '20',
'type': 'archive',
'after': 'page2',
},
);
final videos = await api.getVideos(userId: '111', cursor: 'page2');
expect(videos.data, isEmpty);
});
});
} }

View file

@ -0,0 +1,208 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/constants.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('getVideoComments', () {
test('fetches comments by content offset', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c1',
'contentOffsetSeconds': 10,
'commenter': {
'id': 'u1',
'displayName': 'User1',
'login': 'user1',
},
'message': {
'userColor': '#FF0000',
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'hello', 'emote': null},
],
},
},
'cursor': 'cursor-1',
},
],
'pageInfo': {'hasNextPage': true},
},
},
},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'v123',
contentOffsetSeconds: 0,
);
expect(response.comments, hasLength(1));
expect(response.comments.first.id, 'c1');
expect(response.comments.first.commenterLogin, 'user1');
expect(response.hasNextPage, isTrue);
expect(response.cursor, 'cursor-1');
});
test('fetches comments by cursor', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c2',
'contentOffsetSeconds': 20,
'commenter': {
'id': 'u2',
'displayName': 'User2',
'login': 'user2',
},
'message': {
'userColor': null,
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'world', 'emote': null},
],
},
},
'cursor': 'cursor-2',
},
],
'pageInfo': {'hasNextPage': false},
},
},
},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'cursor': 'cursor-1',
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'v123',
cursor: 'cursor-1',
);
expect(response.comments, hasLength(1));
expect(response.comments.first.id, 'c2');
expect(response.hasNextPage, isFalse);
});
test('handles null video (VOD not found)', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {'video': null},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'nonexistent',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'nonexistent',
contentOffsetSeconds: 0,
);
expect(response.comments, isEmpty);
expect(response.hasNextPage, isFalse);
});
test('throws on GQL errors', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'errors': [
{'message': 'Something went wrong'},
],
'data': null,
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
expect(
() => api.getVideoComments(
videoId: 'v123',
contentOffsetSeconds: 0,
),
throwsA(isA<Exception>()),
);
});
});
}

View file

@ -0,0 +1,216 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
void main() {
group('GqlChatComment', () {
test('fromGqlNode parses a complete comment', () {
final node = {
'id': 'comment-1',
'contentOffsetSeconds': 120,
'commenter': {
'id': 'user-123',
'displayName': 'TestUser',
'login': 'testuser',
},
'message': {
'userColor': '#FF0000',
'userBadges': [
{'setID': 'subscriber', 'version': '12'},
],
'fragments': [
{'text': 'Hello world ', 'emote': null},
{
'text': 'Kappa',
'emote': {'emoteID': '25'},
},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.id, 'comment-1');
expect(comment.contentOffsetSeconds, 120);
expect(comment.commenterLogin, 'testuser');
expect(comment.commenterDisplayName, 'TestUser');
expect(comment.commenterId, 'user-123');
expect(comment.userColor, '#FF0000');
expect(comment.userBadges.length, 1);
expect(comment.userBadges.first.setId, 'subscriber');
expect(comment.userBadges.first.version, '12');
expect(comment.messageFragments.length, 2);
expect(comment.messageFragments[0].text, 'Hello world ');
expect(comment.messageFragments[0].emote, isNull);
expect(comment.messageFragments[1].text, 'Kappa');
expect(comment.messageFragments[1].emote!.emoteId, '25');
});
test('fromGqlNode handles deleted commenter (null)', () {
final node = {
'id': 'comment-2',
'contentOffsetSeconds': 60,
'commenter': null,
'message': {
'userColor': null,
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'deleted message', 'emote': null},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.commenterLogin, isNull);
expect(comment.commenterDisplayName, isNull);
expect(comment.commenterId, isNull);
expect(comment.userColor, isNull);
expect(comment.userBadges, isEmpty);
});
});
group('GqlChatResponse', () {
test('fromJson parses a valid response', () {
final json = {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c1',
'contentOffsetSeconds': 10,
'commenter': {
'id': 'u1',
'displayName': 'User1',
'login': 'user1',
},
'message': {
'userColor': '#00FF00',
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'hello', 'emote': null},
],
},
},
'cursor': 'cursor-abc',
},
],
'pageInfo': {'hasNextPage': true},
},
},
},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments.length, 1);
expect(response.comments.first.id, 'c1');
expect(response.hasNextPage, isTrue);
expect(response.cursor, 'cursor-abc');
});
test('fromJson throws on GQL errors', () {
final json = {
'errors': [
{'message': 'Something went wrong'},
],
'data': null,
};
expect(
() => GqlChatResponse.fromJson(json),
throwsA(isA<Exception>()),
);
});
test('fromJson handles null video (VOD not found)', () {
final json = {
'data': {'video': null},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments, isEmpty);
expect(response.hasNextPage, isFalse);
});
});
group('IRCMessage.fromGqlComment', () {
test('converts comment to IRCMessage with correct tags', () {
final comment = GqlChatComment(
id: 'msg-1',
contentOffsetSeconds: 300,
commenterLogin: 'testuser',
commenterDisplayName: 'TestUser',
commenterId: 'user-123',
userColor: '#FF0000',
userBadges: [
const GqlChatBadge(setId: 'subscriber', version: '3'),
const GqlChatBadge(setId: 'moderator', version: '1'),
],
messageFragments: [
const GqlChatFragment(text: 'Hello world'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.command, Command.privateMessage);
expect(irc.user, 'testuser');
expect(irc.tags['display-name'], 'TestUser');
expect(irc.tags['user-id'], 'user-123');
expect(irc.tags['id'], 'msg-1');
expect(irc.tags['color'], '#FF0000');
expect(irc.tags['badges'], 'subscriber/3,moderator/1');
expect(irc.tags['tmi-sent-ts'], '300000');
expect(irc.message, 'Hello world');
expect(irc.split, ['Hello', 'world']);
expect(irc.localEmotes, isNull);
});
test('populates localEmotes for Twitch emotes', () {
final comment = GqlChatComment(
id: 'msg-2',
contentOffsetSeconds: 10,
commenterLogin: 'user',
commenterDisplayName: 'User',
commenterId: 'u1',
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'Hey '),
const GqlChatFragment(
text: 'Kappa',
emote: GqlChatEmote(emoteId: '25'),
),
const GqlChatFragment(text: ' nice'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.message, 'Hey Kappa nice');
expect(irc.localEmotes, isNotNull);
expect(irc.localEmotes!['Kappa'], isNotNull);
expect(irc.localEmotes!['Kappa']!.name, 'Kappa');
expect(irc.localEmotes!['Kappa']!.url, contains('25'));
});
test('handles deleted commenter', () {
final comment = GqlChatComment(
id: 'msg-3',
contentOffsetSeconds: 0,
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'deleted message'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.tags['display-name'], 'Deleted User');
expect(irc.user, isNull);
expect(irc.tags['user-id'], '');
});
});
}

128
test/models/video_test.dart Normal file
View file

@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/video.dart';
void main() {
group('VideoTwitch', () {
test('fromJson parses all fields correctly', () {
final json = {
'id': '12345',
'stream_id': '67890',
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test Stream VOD',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '3h26m15s',
'thumbnail_url':
'https://static-cdn.jtvnw.net/cf_vods/abc/%{width}x%{height}.jpg',
'view_count': 12345,
};
final video = VideoTwitch.fromJson(json);
expect(video.id, '12345');
expect(video.streamId, '67890');
expect(video.userId, '111');
expect(video.userLogin, 'testuser');
expect(video.userName, 'TestUser');
expect(video.title, 'Test Stream VOD');
expect(video.createdAt, '2024-01-15T10:30:00Z');
expect(video.publishedAt, '2024-01-15T10:30:00Z');
expect(video.duration, '3h26m15s');
expect(video.thumbnailUrl, contains('%{width}'));
expect(video.viewCount, 12345);
});
test('fromJson handles null streamId', () {
final json = {
'id': '12345',
'stream_id': null,
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '1h0m0s',
'thumbnail_url': '',
'view_count': 0,
};
final video = VideoTwitch.fromJson(json);
expect(video.streamId, isNull);
});
});
group('VideosTwitch', () {
test('fromJson parses data list and pagination', () {
final json = {
'data': [
{
'id': '1',
'stream_id': null,
'user_id': '111',
'user_login': 'user',
'user_name': 'User',
'title': 'Title',
'created_at': '2024-01-01T00:00:00Z',
'published_at': '2024-01-01T00:00:00Z',
'duration': '1h0m0s',
'thumbnail_url': '',
'view_count': 100,
},
],
'pagination': {'cursor': 'abc123'},
};
final videos = VideosTwitch.fromJson(json);
expect(videos.data.length, 1);
expect(videos.data.first.id, '1');
expect(videos.pagination['cursor'], 'abc123');
});
test('fromJson handles empty data', () {
final json = {
'data': <Map<String, dynamic>>[],
'pagination': <String, String>{},
};
final videos = VideosTwitch.fromJson(json);
expect(videos.data, isEmpty);
});
});
group('parseTwitchDuration', () {
test('parses hours, minutes, and seconds', () {
expect(
parseTwitchDuration('3h26m15s'),
const Duration(hours: 3, minutes: 26, seconds: 15),
);
});
test('parses minutes and seconds only', () {
expect(
parseTwitchDuration('45m30s'),
const Duration(minutes: 45, seconds: 30),
);
});
test('parses seconds only', () {
expect(
parseTwitchDuration('30s'),
const Duration(seconds: 30),
);
});
test('parses hours and minutes only', () {
expect(
parseTwitchDuration('2h10m'),
const Duration(hours: 2, minutes: 10),
);
});
test('returns Duration.zero for empty string', () {
expect(parseTwitchDuration(''), Duration.zero);
});
});
}