diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..b03f2f3c 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -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 getVideos({ + required String userId, + String? cursor, + }) async { + final queryParams = { + 'user_id': userId, + 'first': '20', + 'type': 'archive', + }; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get('/videos', queryParameters: queryParams); + + return VideosTwitch.fromJson(data); + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 11f67102..24a9b83f 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -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 getVideoComments({ + required String videoId, + int? contentOffsetSeconds, + String? cursor, + }) async { + final headers = { + 'Client-ID': twitchGqlMobileClientId, + }; + + final variables = { + '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( + '/gql', + data: body, + headers: headers, + ); + + return GqlChatResponse.fromJson(response); + } } diff --git a/lib/constants.dart b/lib/constants.dart index a09e6d9e..a81d1943 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -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'); diff --git a/lib/models/gql_chat_comment.dart b/lib/models/gql_chat_comment.dart new file mode 100644 index 00000000..bdbb628a --- /dev/null +++ b/lib/models/gql_chat_comment.dart @@ -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 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 json) { + return GqlChatFragment( + text: json['text'] as String?, + emote: json['emote'] != null + ? GqlChatEmote.fromJson(json['emote'] as Map) + : null, + ); + } +} + +class GqlChatBadge { + final String setId; + final String version; + + const GqlChatBadge({required this.setId, required this.version}); + + factory GqlChatBadge.fromJson(Map 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 userBadges; + final List 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 node) { + final commenter = node['commenter'] as Map?; + final message = node['message'] as Map; + + 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)) + .toList(), + messageFragments: (message['fragments'] as List) + .map((f) => GqlChatFragment.fromJson(f as Map)) + .toList(), + ); + } +} + +class GqlChatResponse { + final List comments; + final bool hasNextPage; + final String? cursor; + + const GqlChatResponse({ + required this.comments, + required this.hasNextPage, + this.cursor, + }); + + factory GqlChatResponse.fromJson(Map 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)['message'] as String? + : 'Unknown GQL error'; + throw Exception('GQL error: $message'); + } + + final video = json['data']?['video'] as Map?; + if (video == null) { + return const GqlChatResponse( + comments: [], + hasNextPage: false, + ); + } + + final commentsData = video['comments'] as Map; + final edges = commentsData['edges'] as List; + final pageInfo = commentsData['pageInfo'] as Map; + + final comments = edges + .map((edge) => GqlChatComment.fromGqlNode( + (edge as Map)['node'] as Map, + )) + .toList(); + + final cursor = edges.isNotEmpty + ? (edges.last as Map)['cursor'] as String? + : null; + + return GqlChatResponse( + comments: comments, + hasNextPage: pageInfo['hasNextPage'] as bool, + cursor: cursor, + ); + } +} diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 8572d01f..cb214177 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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 = { + '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 = {}; + 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, diff --git a/lib/models/video.dart b/lib/models/video.dart new file mode 100644 index 00000000..26bdc882 --- /dev/null +++ b/lib/models/video.dart @@ -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 json) => + _$VideoTwitchFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class VideosTwitch { + final List data; + final Map pagination; + + const VideosTwitch(this.data, this.pagination); + + factory VideosTwitch.fromJson(Map 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); +} diff --git a/lib/models/video.g.dart b/lib/models/video.g.dart new file mode 100644 index 00000000..202fa376 --- /dev/null +++ b/lib/models/video.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'video.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VideoTwitch _$VideoTwitchFromJson(Map 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 json) => VideosTwitch( + (json['data'] as List) + .map((e) => VideoTwitch.fromJson(e as Map)) + .toList(), + Map.from(json['pagination'] as Map), +); diff --git a/lib/screens/channel/chat/chat_render_context.dart b/lib/screens/channel/chat/chat_render_context.dart new file mode 100644 index 00000000..533dc668 --- /dev/null +++ b/lib/screens/channel/chat/chat_render_context.dart @@ -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 get revealedMessageIds; + void revealMessage(String id); + void updateNotification(String message); +} diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 987e15bf..e8563d78 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -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 { ), ), ), + 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'), diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..3526c955 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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(); + @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 diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 8beb8008..bdad638d 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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.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 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, ); }, ), diff --git a/lib/screens/channel/vod/stores/vod_chat_store.dart b/lib/screens/channel/vod/stores/vod_chat_store.dart new file mode 100644 index 00000000..f1c81b94 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.dart @@ -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(); + + 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(); + + /// Timer used for dismissing the notification. + Timer? _notificationTimer; + + /// Fetched GQL comments waiting to be advanced based on playback position. + final _commentBuffer = Queue(); + + /// 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(); + + /// 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 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 init() async { + await Future.wait([ + assetsStore.init().then((_) => _loadAssets()), + _fetchComments(contentOffsetSeconds: 0), + ]); + _startMessageBufferTimer(); + _setupScrollListener(); + _isInitialized = true; + } + + Future _loadAssets() async { + await assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + 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 handleSeek(int offsetSeconds) async { + playbackPosition = offsetSeconds; + _commentBuffer.clear(); + messageBuffer.clear(); + _messages.clear(); + _lastRenderedOffset = -1; + _nextCursor = null; + await _fetchComments(contentOffsetSeconds: offsetSeconds); + } + + Future _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(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_chat_store.g.dart b/lib/screens/channel/vod/stores/vod_chat_store.g.dart new file mode 100644 index 00000000..dbf39b1a --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.g.dart @@ -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>? _$renderMessagesComputed; + + @override + List get renderMessages => + (_$renderMessagesComputed ??= Computed>( + () => 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 get messages { + _$_messagesAtom.reportRead(); + return super._messages; + } + + @override + ObservableList get _messages => messages; + + @override + set _messages(ObservableList 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} + '''; + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.dart b/lib/screens/channel/vod/stores/vod_list_store.dart new file mode 100644 index 00000000..997723ed --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.dart @@ -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(); + + @readonly + var _isLoading = false; + + @readonly + var _error = ''; + + String? _cursor; + bool _hasMore = true; + + VodListStoreBase({ + required this.twitchApi, + required this.userId, + }); + + @action + Future 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 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 refresh() async { + _cursor = null; + _hasMore = true; + await fetchVideos(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.g.dart b/lib/screens/channel/vod/stores/vod_list_store.g.dart new file mode 100644 index 00000000..dbd132b2 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.g.dart @@ -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 get videos { + _$_videosAtom.reportRead(); + return super._videos; + } + + @override + ObservableList get _videos => videos; + + @override + set _videos(ObservableList 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 fetchVideos() { + return _$fetchVideosAsyncAction.run(() => super.fetchVideos()); + } + + late final _$fetchMoreAsyncAction = AsyncAction( + 'VodListStoreBase.fetchMore', + context: context, + ); + + @override + Future fetchMore() { + return _$fetchMoreAsyncAction.run(() => super.fetchMore()); + } + + late final _$refreshAsyncAction = AsyncAction( + 'VodListStoreBase.refresh', + context: context, + ); + + @override + Future refresh() { + return _$refreshAsyncAction.run(() => super.refresh()); + } + + @override + String toString() { + return ''' + + '''; + } +} diff --git a/lib/screens/channel/vod/vod_card.dart b/lib/screens/channel/vod/vod_card.dart new file mode 100644 index 00000000..0fed02c5 --- /dev/null +++ b/lib/screens/channel/vod/vod_card.dart @@ -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(); + } +} diff --git a/lib/screens/channel/vod/vod_list.dart b/lib/screens/channel/vod/vod_list.dart new file mode 100644 index 00000000..d505c7d6 --- /dev/null +++ b/lib/screens/channel/vod/vod_list.dart @@ -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 createState() => _VodListState(); +} + +class _VodListState extends State { + 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( + 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]); + }, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/channel/vod/vod_player.dart b/lib/screens/channel/vod/vod_player.dart new file mode 100644 index 00000000..f579a686 --- /dev/null +++ b/lib/screens/channel/vod/vod_player.dart @@ -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 createState() => _VodPlayerState(); +} + +class _VodPlayerState extends State { + 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 {}, + ); + } 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 _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(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 898cb8d2..4c60704f 100644 --- a/pubspec.lock +++ b/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: diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 5b5df81a..d100ace3 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -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': >[], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '111', + 'first': '20', + 'type': 'archive', + 'after': 'page2', + }, + ); + + final videos = await api.getVideos(userId: '111', cursor: 'page2'); + + expect(videos.data, isEmpty); + }); + }); } diff --git a/test/apis/twitch_gql_api_test.dart b/test/apis/twitch_gql_api_test.dart new file mode 100644 index 00000000..d4817997 --- /dev/null +++ b/test/apis/twitch_gql_api_test.dart @@ -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': >[], + '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': >[], + '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()), + ); + }); + }); +} diff --git a/test/models/gql_chat_comment_test.dart b/test/models/gql_chat_comment_test.dart new file mode 100644 index 00000000..640ee07c --- /dev/null +++ b/test/models/gql_chat_comment_test.dart @@ -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': >[], + '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': >[], + '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()), + ); + }); + + 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'], ''); + }); + }); +} diff --git a/test/models/video_test.dart b/test/models/video_test.dart new file mode 100644 index 00000000..2cc218bc --- /dev/null +++ b/test/models/video_test.dart @@ -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': >[], + 'pagination': {}, + }; + + 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); + }); + }); +}