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>
60 lines
1.6 KiB
Dart
60 lines
1.6 KiB
Dart
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);
|
|
}
|