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:
parent
6695c13455
commit
e73d9b255f
23 changed files with 2279 additions and 57 deletions
|
|
@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart';
|
|||
import 'package:frosty/models/shared_chat_session.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/models/video.dart';
|
||||
|
||||
/// The Twitch service for making API calls.
|
||||
class TwitchApi extends BaseApiClient {
|
||||
|
|
@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient {
|
|||
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.
|
||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||
required String gameId,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import 'dart:math';
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/gql_chat_comment.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)
|
||||
/// since the GQL endpoint requires it.
|
||||
/// Uses the Twitch web client ID for playback tokens and the mobile
|
||||
/// client ID for VOD chat (bypasses Client-Integrity checks).
|
||||
class TwitchGqlApi extends BaseApiClient {
|
||||
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
|
||||
|
||||
|
|
@ -66,4 +67,47 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
'&playlist_include_framerate=true'
|
||||
'&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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
|||
/// SharedPreferences key for the last-selected 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.
|
||||
const secret = String.fromEnvironment('SECRET');
|
||||
|
||||
|
|
|
|||
137
lib/models/gql_chat_comment.dart
Normal file
137
lib/models/gql_chat_comment.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart';
|
|||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/badges.dart';
|
||||
import 'package:frosty/models/emotes.dart';
|
||||
import 'package:frosty/models/gql_chat_comment.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_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({
|
||||
required String message,
|
||||
VoidCallback? actionCallback,
|
||||
|
|
|
|||
60
lib/models/video.dart
Normal file
60
lib/models/video.dart
Normal 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
28
lib/models/video.g.dart
Normal 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),
|
||||
);
|
||||
18
lib/screens/channel/chat/chat_render_context.dart
Normal file
18
lib/screens/channel/chat/chat_render_context.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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_users_list.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/utils.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(
|
||||
leading: const Icon(Icons.add_comment_rounded),
|
||||
title: const Text('Add chat'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart';
|
|||
import 'package:frosty/models/emotes.dart';
|
||||
import 'package:frosty/models/events.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/stores/chat_assets_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.
|
||||
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.
|
||||
static const _messageLimit = 5000;
|
||||
|
||||
|
|
@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store {
|
|||
final _messagesToRemove = (_messageLimit * 0.2).toInt();
|
||||
|
||||
/// The provided auth store to determine login status, get the token, and use the headers for requests.
|
||||
@override
|
||||
final AuthStore auth;
|
||||
|
||||
/// The provided setting store to account for any user-defined behaviors.
|
||||
@override
|
||||
final SettingsStore settings;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// The channel's ID for API requests.
|
||||
@override
|
||||
final String channelId;
|
||||
|
||||
/// The channel's display name to show on widgets.
|
||||
|
|
@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store {
|
|||
final ChatDetailsStore chatDetailsStore;
|
||||
|
||||
/// The assets store responsible for badges, emotes, and the emote menu.
|
||||
@override
|
||||
final ChatAssetsStore assetsStore;
|
||||
|
||||
/// 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>[];
|
||||
|
||||
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
||||
@override
|
||||
final revealedMessageIds = ObservableSet<String>();
|
||||
|
||||
@override
|
||||
@action
|
||||
void revealMessage(String 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].
|
||||
@override
|
||||
@action
|
||||
void updateNotification(String notificationMessage) {
|
||||
// Cancel the previous notification to prevent the notification from phasing in and out
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart';
|
|||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/irc.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/widgets/chat_user_modal.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
||||
|
|
@ -15,7 +16,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
final IRCMessage ircMessage;
|
||||
final ChatStore chatStore;
|
||||
final ChatRenderContext chatStore;
|
||||
final bool isModal;
|
||||
final bool showReplyHeader;
|
||||
final bool isInReplyThread;
|
||||
|
|
@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget {
|
|||
// Ignore if the message is a recent message in the modal bottom sheet.
|
||||
if (isModal) return;
|
||||
|
||||
// User modal requires live ChatStore (recent messages, blocking, etc.).
|
||||
if (chatStore is! ChatStore) return;
|
||||
|
||||
// Ignore if long-pressing own username.
|
||||
if (ircMessage.user == null ||
|
||||
ircMessage.user == chatStore.auth.user.details?.login) {
|
||||
|
|
@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget {
|
|||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => ChatUserModal(
|
||||
chatStore: chatStore,
|
||||
chatStore: chatStore as ChatStore,
|
||||
username: ircMessage.user!,
|
||||
userId: ircMessage.tags['user-id']!,
|
||||
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.
|
||||
if (isModal) return;
|
||||
|
||||
// User modal requires live ChatStore.
|
||||
if (chatStore is! ChatStore) return;
|
||||
|
||||
final twitchApi = context.read<TwitchApi>();
|
||||
twitchApi.getUser(userLogin: nickname).then((user) {
|
||||
if (context.mounted) {
|
||||
|
|
@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget {
|
|||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => ChatUserModal(
|
||||
chatStore: chatStore,
|
||||
chatStore: chatStore as ChatStore,
|
||||
username: user.login,
|
||||
userId: user.id,
|
||||
displayName: user.displayName,
|
||||
|
|
@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
|
||||
|
|
@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget {
|
|||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy message'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () async {
|
||||
await copyMessage();
|
||||
if (chatStore is ChatStore) ...[
|
||||
ListTile(
|
||||
onTap: () async {
|
||||
await copyMessage();
|
||||
final liveChatStore = chatStore as ChatStore;
|
||||
|
||||
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
|
||||
final effectiveChatDelay =
|
||||
liveChatStore.settings.effectiveChatDelay;
|
||||
|
||||
final hasChatDelay =
|
||||
chatStore.settings.showVideo && effectiveChatDelay > 0;
|
||||
final hasChatDelay =
|
||||
liveChatStore.settings.showVideo && effectiveChatDelay > 0;
|
||||
|
||||
if (hasChatDelay) {
|
||||
_effectiveInputStore.updateNotification(
|
||||
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
|
||||
);
|
||||
if (hasChatDelay) {
|
||||
_effectiveInputStore.updateNotification(
|
||||
'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) {
|
||||
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
|
||||
// targets the correct channel's composer.
|
||||
onActivateSourceTab?.call();
|
||||
|
||||
// Paste the copied message into the text controller
|
||||
chatStore.textController.text = ircMessage.message ?? '';
|
||||
chatStore.safeRequestFocus();
|
||||
if (context.mounted) {
|
||||
liveChatStore.replyingToMessage = ircMessage;
|
||||
liveChatStore.safeRequestFocus();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
leading: const Icon(Icons.content_paste),
|
||||
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'),
|
||||
),
|
||||
},
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply to message'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget {
|
|||
color: messageHeaderTextColor,
|
||||
);
|
||||
messageHeader = GestureDetector(
|
||||
onTap: isModal
|
||||
onTap: isModal || chatStore is! ChatStore
|
||||
? null
|
||||
: () => showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ReplyThread(
|
||||
selectedMessage: ircMessage,
|
||||
chatStore: chatStore,
|
||||
chatStore: chatStore as ChatStore,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
284
lib/screens/channel/vod/stores/vod_chat_store.dart
Normal file
284
lib/screens/channel/vod/stores/vod_chat_store.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
203
lib/screens/channel/vod/stores/vod_chat_store.g.dart
Normal file
203
lib/screens/channel/vod/stores/vod_chat_store.g.dart
Normal 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
73
lib/screens/channel/vod/stores/vod_list_store.dart
Normal file
73
lib/screens/channel/vod/stores/vod_list_store.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
108
lib/screens/channel/vod/stores/vod_list_store.g.dart
Normal file
108
lib/screens/channel/vod/stores/vod_list_store.g.dart
Normal 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 '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
184
lib/screens/channel/vod/vod_card.dart
Normal file
184
lib/screens/channel/vod/vod_card.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
82
lib/screens/channel/vod/vod_list.dart
Normal file
82
lib/screens/channel/vod/vod_list.dart
Normal 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]);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
282
lib/screens/channel/vod/vod_player.dart
Normal file
282
lib/screens/channel/vod/vod_player.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -190,10 +190,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -797,18 +797,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1267,10 +1267,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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/models/emotes.dart';
|
||||
import 'package:frosty/models/video.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
import '../fixtures/api_responses.dart';
|
||||
|
|
@ -389,4 +390,63 @@ void main() {
|
|||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
208
test/apis/twitch_gql_api_test.dart
Normal file
208
test/apis/twitch_gql_api_test.dart
Normal 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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
216
test/models/gql_chat_comment_test.dart
Normal file
216
test/models/gql_chat_comment_test.dart
Normal 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
128
test/models/video_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue