Skip to content

Commit

Permalink
Merge pull request #192 from LezdCS/develop
Browse files Browse the repository at this point in the history
feat/youtube-chat
  • Loading branch information
LezdCS authored May 30, 2024
2 parents d10329e + e8f9374 commit 2b92d76
Show file tree
Hide file tree
Showing 11 changed files with 378 additions and 42 deletions.
Binary file added lib/assets/kick/kickLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/assets/youtube/youtubeLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
189 changes: 189 additions & 0 deletions lib/src/core/utils/youtube_chat.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import 'dart:async';
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:http/http.dart' as http;
import 'package:html/parser.dart' as parser;
import 'package:http/http.dart';
import 'package:irllink/src/core/utils/globals.dart' as globals;
import 'package:irllink/src/domain/entities/chat/chat_message.dart';

class YoutubeChat {
String videoId;

final StreamController _chatStreamController = StreamController.broadcast();
Stream get chatStream => _chatStreamController.stream;

YoutubeChat(
this.videoId,
);

void closeStream() {
_chatStreamController.close();
}

// Function to fetch the initial continuation token
Future<String?> fetchInitialContinuationToken() async {
var headers = {
'accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'accept-language': 'en-US,en;q=0.9',
'cache-control': 'no-cache',
'pragma': 'no-cache',
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'
};

String url = 'https://www.youtube.com/live_chat?is_popout=1&v=$videoId';
try {
Response response = await http.get(Uri.parse(url), headers: headers);
Document document = parser.parse(response.body);
List<Element> scripts = document.getElementsByTagName('script');
Element ytInitialDataScript = scripts
.firstWhere((script) => script.innerHtml.contains('ytInitialData'));
String scriptContent = ytInitialDataScript.innerHtml;
// Extracting ytInitialData JSON from the script tag
int dataStart = scriptContent.indexOf('{');
int dataEnd = scriptContent.lastIndexOf('}') + 1;
String jsonString = scriptContent.substring(dataStart, dataEnd);
dynamic ytInitialData = json.decode(jsonString);

// Extract continuation token from ytInitialData
dynamic continuation = ytInitialData['contents']['liveChatRenderer']
['continuations'][0]['invalidationContinuationData']['continuation'];
return continuation;
} catch (error) {
globals.talker
?.error('Error fetching initial continuation token: $error');
return null;
}
}

Future<String?> fetchChatMessages(String? continuationToken) async {
String body = json.encode({
'context': {
'client': {
'hl': 'en',
'gl': 'JP',
'remoteHost': '123.123.123.123',
'userAgent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'clientName': 'WEB',
'clientVersion': '2.20240411.09.00',
},
'user': {'lockedSafetyMode': false}
},
'continuation': continuationToken
});

Map<String, Map<String, String>> options = {
'headers': {'Content-Type': 'application/json'},
};

const url =
'https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?prettyPrint=false';

try {
Response response = await http.post(Uri.parse(url),
headers: options['headers'], body: body);
dynamic data = json.decode(response.body);


Iterable<dynamic>? messagesData = (data['continuationContents']
['liveChatContinuation']['actions'] as List?)
?.map((action) => (action['addChatItemAction']['item']
['liveChatTextMessageRenderer']));

messagesData?.forEach((message) {
if(message['message'] == null) return;
List? messages = (message['message']['runs'] as List?)
?.map((run) => run['text'])
.where((message) => message != null)
.toList();
if(messages == null) return;
if(messages.isEmpty) return;
ChatMessage msg = ChatMessage.fromYoutube(message, messages, videoId);
_chatStreamController.add(msg);
});

dynamic newContinuationToken = data['continuationContents']
['liveChatContinuation']['continuations'][0]
['invalidationContinuationData']['continuation'];
if (newContinuationToken == null) {
globals.talker?.info('No continuation token found, terminating.');
return null;
}
return newContinuationToken;
} catch (error) {
globals.talker?.error('Error fetching chat messages: $error');
return continuationToken; // Retry with the same token
}
}

Future<void> startFetchingChat() async {
var continuationToken = await fetchInitialContinuationToken();
if (continuationToken == null) {
globals.talker?.error('Failed to fetch initial continuation token.');
return;
}

while (continuationToken != null) {
if(_chatStreamController.isClosed) return;
continuationToken = await fetchChatMessages(continuationToken);
await Future.delayed(const Duration(seconds: 5)); // 5-second pause
}
}
}

Future<String?> getLiveVideoId(String channelURL) async {
// Send GET request to the YouTube channel's live streams page
var response = await http.get(Uri.parse(channelURL));
if (response.statusCode != 200) {
globals.talker?.error(
'Failed to retrieve the page. Status code: ${response.statusCode}');
return null;
}

// Extract the ytInitialData JSON data from the page content
var match = RegExp(r'ytInitialData\s*=\s*({.*?});</script>')
.firstMatch(response.body);
if (match == null) {
globals.talker?.error('Failed to find ytInitialData in the page content.');
return null;
}

var ytInitialData = json.decode(match.group(1)!);

// Navigate through the JSON data to find the live video ID
try {
var tabs =
ytInitialData['contents']['twoColumnBrowseResultsRenderer']['tabs'];
for (var tab in tabs) {
if (tab is Map &&
tab['tabRenderer'] != null &&
tab['tabRenderer']['selected'] != null &&
tab['tabRenderer']['content'] != null) {
var contents =
tab['tabRenderer']['content']['richGridRenderer']['contents'];
for (var content in contents) {
if (content is Map &&
content['richItemRenderer'] != null &&
content['richItemRenderer']['content'] != null &&
content['richItemRenderer']['content']['videoRenderer'] != null &&
content['richItemRenderer']['content']['videoRenderer']
['upcomingEventData'] ==
null) {
var videoId = content['richItemRenderer']['content']
['videoRenderer']['videoId'];
return videoId;
}
}
}
}
} catch (e) {
globals.talker?.error('Error parsing ytInitialData: $e');
return null;
}

globals.talker?.info('No live video found.');
return null;
}
43 changes: 41 additions & 2 deletions lib/src/domain/entities/chat/chat_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum EventType {
enum Platform {
twitch,
kick,
youtube,
}

// ignore: must_be_immutable
Expand Down Expand Up @@ -164,11 +165,11 @@ class ChatMessage extends Equatable
isVip: false,
isDeleted: false,
rawData: '',
eventType: null, //TODO: Add event type for KickMessage
eventType: null,
badgesList: message.data.sender.identity.badges
.map((badge) => ChatBadge.fromKick(channelId, badge, subBadges))
.toList(),
emotes: const {}, //TODO: Add emotes for KickMessage
emotes: const {},
platform: Platform.kick,
channelId: channelId,

Expand All @@ -186,6 +187,44 @@ class ChatMessage extends Equatable
);
}

factory ChatMessage.fromYoutube(dynamic messageRaw, List? messages, String videoId) {
String authorName = messageRaw['authorName']['simpleText'];
String id = messageRaw['id'];
String timestamp = messageRaw['timestampUsec'];
return ChatMessage(
id: id,
authorId: '',
displayName: authorName,
username: authorName,
color: '#FFFFFF',
message: messages?[0],
timestamp: int.parse(timestamp),
isAction: false,
isSubscriber: false,
isModerator: false,
isVip: false,
isDeleted: false,
rawData: '',
eventType: null,
badgesList: const [],
emotes: const {},
platform: Platform.youtube,
channelId: videoId,

//implements
raidingChannelName: '',
badges: const [],
giftedName: '',
highlightType: null,
isGift: false,
months: '',
systemMessage: '',
tier: '',
totalBits: 0,
viewerCount: 0,
);
}

factory ChatMessage.kickSub(
KickSubscription sub, String channelId, List<KickBadge> subBadges) {
return ChatMessage(
Expand Down
52 changes: 52 additions & 0 deletions lib/src/presentation/controllers/chat_view_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:get/get.dart';
import 'package:irllink/src/core/utils/constants.dart';
import 'package:irllink/src/core/utils/youtube_chat.dart';
import 'package:irllink/src/domain/entities/chat/chat_emote.dart';
import 'package:irllink/src/domain/entities/chat/chat_message.dart';
import 'package:irllink/src/domain/entities/settings/chat_settings.dart';
Expand Down Expand Up @@ -49,6 +50,7 @@ class ChatViewController extends GetxController

List<TwitchChat> twitchChats = [];
List<KickChat> kickChats = [];
List<YoutubeChat> youtubeChats = [];

@override
void onInit() async {
Expand Down Expand Up @@ -248,6 +250,9 @@ class ChatViewController extends GetxController
chatGroup.channels.where((e) => e.platform == Platform.twitch).toList();
List<Channel> kickChannels =
chatGroup.channels.where((e) => e.platform == Platform.kick).toList();
List<Channel> youtubeChannels = chatGroup.channels
.where((e) => e.platform == Platform.youtube)
.toList();

for (Channel tc in twitchChannels) {
bool alreadyCreated =
Expand All @@ -266,6 +271,15 @@ class ChatViewController extends GetxController
createKickChat(kc);
}

for (Channel kc in youtubeChannels) {
bool alreadyCreated =
kickChats.firstWhereOrNull((k) => k.username == kc.channel) != null;
if (alreadyCreated) {
return;
}
createYoutubeChat(kc.channel);
}

// Remove
List<TwitchChat> twitchChatToRemove = twitchChats
.where((tc) =>
Expand All @@ -279,6 +293,12 @@ class ChatViewController extends GetxController
.firstWhereOrNull((kCa) => kCa.channel == kc.username) ==
null)
.toList();
List<YoutubeChat> youtubeChatToRemove = youtubeChats
.where((yc) =>
youtubeChannels
.firstWhereOrNull((yCa) => yCa.channel == yc.videoId) ==
null)
.toList();

for (TwitchChat t in twitchChatToRemove) {
t.close();
Expand All @@ -289,6 +309,11 @@ class ChatViewController extends GetxController
k.close();
kickChats.removeWhere((kc) => kc.username == k.username);
}

for (YoutubeChat y in youtubeChatToRemove) {
y.closeStream();
youtubeChats.removeWhere((yc) => yc.videoId == y.videoId);
}
}

void createTwitchChat(Channel tc) {
Expand Down Expand Up @@ -374,6 +399,33 @@ class ChatViewController extends GetxController
});
}

Future<void> createYoutubeChat(String channelId) async {
String? videoId = await getLiveVideoId(channelId);
if (videoId == null) {
globals.talker?.error('VideoID not found for the channel: $channelId');
return;
}

YoutubeChat youtubeChat = YoutubeChat(
videoId,
);
youtubeChat.startFetchingChat();
youtubeChat.chatStream.listen((message) {
chatMessages.add(message);
if (scrollController.hasClients && isAutoScrolldown.value) {
Timer(const Duration(milliseconds: 100), () {
if (isAutoScrolldown.value) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
});
youtubeChats.add(youtubeChat);
isChatConnected.value = true;
}

Future<void> createKickChat(Channel kc) async {
KickChat kickChat = KickChat(
kc.channel,
Expand Down
Loading

0 comments on commit 2b92d76

Please sign in to comment.