From bd866ba1bfd543219b666375bc626111a732200f Mon Sep 17 00:00:00 2001 From: ElementalAlchemist Date: Sun, 17 Nov 2024 17:41:19 -0600 Subject: [PATCH] Chat replay --- thrimbletrimmer/src/common/chat.module.scss | 50 +++ thrimbletrimmer/src/common/chat.tsx | 332 ++++++++++++++++-- thrimbletrimmer/src/common/convertTime.tsx | 5 +- thrimbletrimmer/src/restreamer/Restreamer.tsx | 36 +- 4 files changed, 390 insertions(+), 33 deletions(-) create mode 100644 thrimbletrimmer/src/common/chat.module.scss diff --git a/thrimbletrimmer/src/common/chat.module.scss b/thrimbletrimmer/src/common/chat.module.scss new file mode 100644 index 0000000..efdc39b --- /dev/null +++ b/thrimbletrimmer/src/common/chat.module.scss @@ -0,0 +1,50 @@ +.chatReplayMessage { + display: flex; + align-items: baseline; + gap: 10px; +} + +.chatReplayMessageSystem { + color: #aaf; +} + +.chatReplayMessageTime { + flex-basis: 110px; + color: #ccc; + text-align: right; +} + +.chatReplayMessageText { + flex-basis: 100px; + flex-grow: 1; +} + +.chatReplayMessageReply { + font-size: 80%; + + a { + text-decoration: none; + } +} + +.chatReplayMessageTextAction { + font-style: italic; + + .chatReplayMessageReply:not(.chatReplayMessageTextAction) { + font-style: normal; + } +} + +.chatReplayMessageEmote { + // The sizes here are based on Twitch's 1.0 emote size. + width: 28px; + height: 28px; +} + +.chatReplayMessageCleared { + opacity: 0.5; + + .chatReplayMessageText { + text-decoration: line-through; + } +} diff --git a/thrimbletrimmer/src/common/chat.tsx b/thrimbletrimmer/src/common/chat.tsx index 5c8e328..f5be808 100644 --- a/thrimbletrimmer/src/common/chat.tsx +++ b/thrimbletrimmer/src/common/chat.tsx @@ -1,10 +1,11 @@ +import { Accessor, Component, For, JSX, Show } from "solid-js"; import { StreamVideoInfo } from "./streamInfo"; -import { HLSProvider } from "vidstack"; import { DateTime } from "luxon"; import { Fragment } from "hls.js"; import { wubloaderTimeFromDateTime } from "./convertTime"; +import styles from "./chat.module.scss"; -export interface ChatMessage { +export interface RawChatMessage { command: string; host: string; params: string[]; @@ -16,22 +17,59 @@ export interface ChatMessage { user: string; } -export interface ChatMessageData { - message: ChatMessage; +export class ChatMessageData { + message: RawChatMessage; + messageID: string; + userID: string | null; when: DateTime; whenSeconds: number; whenDisplay: string; } +export class ChatLog { + messages: ChatMessageData[]; + messagesByID: { [id: string]: ChatMessageData }; + messagesBySender: { [username: string]: ChatMessageData[] }; + clearMessages: { [messageID: string]: number }; + clearUsers: { [username: string]: number[] }; + + public constructor( + messages: ChatMessageData[], + clearMessages: { [messageID: string]: number }, + clearUsers: { [username: string]: number[] }, + ) { + const messagesByID: { [id: string]: ChatMessageData } = {}; + const messagesBySender: { [username: string]: ChatMessageData[] } = {}; + + for (const message of messages) { + messagesByID[message.message.tags.id] = message; + if (!messagesBySender.hasOwnProperty(message.message.sender)) { + messagesBySender[message.message.sender] = []; + } + messagesBySender[message.message.sender].push(message); + } + + return { + messages: messages, + messagesByID: messagesByID, + messagesBySender: messagesBySender, + clearMessages: clearMessages, + clearUsers: clearUsers, + }; + } + + static default = () => new ChatLog([], {}, {}); +} + export async function chatData( streamInfo: StreamVideoInfo, fragments: Fragment[], -): Promise { +): Promise { const streamName = streamInfo.streamName; const streamStartTime = streamInfo.streamStartTime; const streamEndTime = streamInfo.streamEndTime; if (!streamEndTime) { - return []; + return ChatLog.default(); } const startWubloaderTime = wubloaderTimeFromDateTime(streamStartTime); const endWubloaderTime = wubloaderTimeFromDateTime(streamEndTime); @@ -39,26 +77,20 @@ export async function chatData( const chatResponse = await fetch(`/${streamName}/chat.json?${params}`); if (!chatResponse.ok) { - return []; + return ChatLog.default(); } - const chatMessages: ChatMessage[] = await chatResponse.json(); + const chatMessages: RawChatMessage[] = await chatResponse.json(); if (!fragments || fragments.length === 0) { - return []; + return ChatLog.default(); } let currentFragmentIndex = 0; let currentFragmentStartTime = DateTime.fromISO(fragments[0].rawProgramDateTime!)!; const chatData: ChatMessageData[] = []; + const clearMessages: { [messageID: string]: number } = {}; + const clearUsers: { [userID: string]: number[] } = {}; for (const chatMessage of chatMessages) { - if ( - chatMessage.command !== "PRIVMSG" && - chatMessage.command !== "CLEARMSG" && - chatMessage.command !== "CLEARCHAT" && - chatMessage.command !== "USERNOTICE" - ) { - continue; - } const when = DateTime.fromSeconds(chatMessage.time); while ( currentFragmentIndex < fragments.length - 1 && @@ -69,16 +101,38 @@ export async function chatData( fragments[currentFragmentIndex].rawProgramDateTime!, ); } - const messageTimeOffset = when.diff(currentFragmentStartTime).seconds; + const messageTimeOffset = when.diff(currentFragmentStartTime).milliseconds / 1000; const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset; - chatData.push({ - message: chatMessage, - when: when, - whenSeconds: messageVideoTime, - whenDisplay: formatDisplayTime(messageVideoTime), - }); + if (chatMessage.command === "PRIVMSG") { + chatData.push({ + message: chatMessage, + messageID: chatMessage.tags["id"], + userID: chatMessage.tags["user-id"], + when: when, + whenSeconds: messageVideoTime, + whenDisplay: formatDisplayTime(messageVideoTime), + }); + } else if (chatMessage.command === "USERNOTICE") { + chatData.push({ + message: chatMessage, + messageID: chatMessage.tags["id"], + userID: null, + when: when, + whenSeconds: messageVideoTime, + whenDisplay: formatDisplayTime(messageVideoTime), + }); + } else if (chatMessage.command === "CLEARMSG") { + const messageID = chatMessage.tags["target-msg-id"]; + clearMessages[messageID] = messageVideoTime; + } else if (chatMessage.command === "CLEARCHAT") { + const userID = chatMessage.tags["target-user-id"]; + if (!clearUsers.hasOwnProperty(userID)) { + clearUsers[userID] = []; + } + clearUsers[userID].push(messageVideoTime); + } } - return chatData; + return new ChatLog(chatData, clearMessages, clearUsers); } function formatDisplayTime(timeSeconds: number): string { @@ -87,9 +141,235 @@ function formatDisplayTime(timeSeconds: number): string { const seconds = Math.floor(timeSeconds % 60) .toString() .padStart(2, "0"); - const milliseconds = Math.floor(timeSeconds * 1000) + const milliseconds = Math.floor((timeSeconds % 1) * 1000) .toString() .padStart(3, "0"); return `${hours}:${minutes}:${seconds}.${milliseconds}`; } + +export interface ChatMessageProps { + chatMessage: ChatMessageData; + chatLog: ChatLog; + videoTime: Accessor; +} + +export const ChatMessage: Component = (props) => { + const message = props.chatMessage; + + const displayChatMessage = () => props.videoTime() >= message.whenSeconds; + + const mayClearMessage = props.chatLog.clearMessages.hasOwnProperty(message.messageID); + const mayClearUser = + message.userID && + props.chatLog.clearUsers.hasOwnProperty(message.userID.toString()) && + props.chatLog.clearUsers[message.userID].some((clearTime) => clearTime > message.whenSeconds); + + const messageCleared = () => { + const messageClearTime = props.chatLog.clearMessages[message.messageID]; + const videoTime = props.videoTime(); + return videoTime >= messageClearTime; + }; + const userCleared = () => { + if (message.userID) { + let userClearTime = 0; + for (const clearTime of props.chatLog.clearUsers[message.userID]) { + if (clearTime >= message.whenSeconds) { + userClearTime = clearTime; + break; + } + } + const videoTime = props.videoTime(); + return videoTime >= userClearTime; + } + return false; + }; + let clearMessageCheck = () => false; + if (mayClearMessage && mayClearUser) { + clearMessageCheck = () => messageCleared() || userCleared(); + } else if (mayClearMessage) { + clearMessageCheck = messageCleared; + } else if (mayClearUser) { + clearMessageCheck = userCleared; + } + + return ( + +
{ + const classList: any = {}; + classList[styles.chatReplayMessage] = true; + classList[styles.chatReplayMessageCleared] = clearMessageCheck(); + return classList; + })()} + > +
{message.whenDisplay}
+ + +
+
+ ); +}; + +export interface SystemMessageProps { + chatMessage: ChatMessageData; + videoTime: Accessor; +} + +export const SystemMessage: Component = (props) => { + const message = props.chatMessage; + + const displaySystemMessage = () => props.videoTime() >= message.whenSeconds; + + const systemMessage = () => { + const systemMsg = message.message.tags["system-msg"]; + if (!systemMsg && message.message.tags["msg-id"] === "announcement") { + return "Announcement"; + } + return systemMsg; + }; + + return ( + +
+
{message.whenDisplay}
+ +
+ {systemMessage()} +
+
+ 1}> +
+
+ + +
+
+
+ ); +}; + +interface MessageSenderProps { + chatMessage: ChatMessageData; +} + +const MessageSender: Component = (props) => { + const message = props.chatMessage.message; + const color = message.tags.hasOwnProperty("color") ? message.tags.color : "inherit"; + return ( +
+ {message.tags["display-name"]} +
+ ); +}; + +interface MessageTextProps { + chatMessage: ChatMessageData; +} + +const MessageText: Component = (props) => { + let chatMessageText = props.chatMessage.message.params[1]; + const messageParts: JSX.Element[] = []; + + let replyData: { id: string; user: string; message: string; isAction: boolean } | null = null; + if (props.chatMessage.message.tags.hasOwnProperty("reply-parent-msg-id")) { + const messageTags = props.chatMessage.message.tags; + let messageText = messageTags["reply-parent-msg-body"]; + const isAction = messageText.startsWith("\u0001ACTION"); + if (isAction) { + const substringEnd = messageText.endsWith("\u0001") + ? messageText.length - 1 + : messageText.length; + messageText = messageText.substring(7, substringEnd); + } + replyData = { + id: messageTags["reply-parent-msg-id"], + user: messageTags["reply-parent-display-name"], + message: messageText, + isAction: isAction, + }; + } + + const isAction = chatMessageText.startsWith("\u0001ACTION"); + if (isAction) { + const substringEnd = chatMessageText.endsWith("\u0001") + ? chatMessageText.length - 1 + : chatMessageText.length; + chatMessageText = chatMessageText.substring(7, substringEnd); + } + + if (props.chatMessage.message.tags.emotes) { + const emoteDataStrings = props.chatMessage.message.tags.emotes.split("/"); + let emotePositions: { emote: string; start: number; end: number }[] = []; + for (const emoteDataString of emoteDataStrings) { + const emoteData = emoteDataString.split(":", 2); + const emoteID = emoteData[0]; + const emotePositionList = emoteData[1].split(",").map((val) => { + const positions = val.split("-"); + return { emote: emoteID, start: +positions[0], end: +positions[1] }; + }); + emotePositions = emotePositions.concat(emotePositionList); + } + emotePositions.sort((a, b) => a.start - b.start); + + let messageTextStart = 0; + while (emotePositions.length > 0) { + const emoteData = emotePositions.shift()!; + const text = chatMessageText.substring(0, emoteData.start - messageTextStart); + if (text !== "") { + messageParts.push(<>{text}); + } + const emoteImageURL = `/segments/emotes/${emoteData.emote}/dark-1.0`; + const emoteText = chatMessageText.substring( + emoteData.start - messageTextStart, + emoteData.end + 1 - messageTextStart, + ); + chatMessageText = chatMessageText.substring(emoteData.end + 1); + messageTextStart = emoteData.end + 1; + messageParts.push( + {emoteText}, + ); + } + if (chatMessageText !== "") { + messageParts.push(<>{chatMessageText}); + } + } else { + messageParts.push(<>{chatMessageText}); + } + + return ( +
{ + const classList: { [className: string]: boolean } = {}; + classList[styles.chatReplayMessageText] = true; + classList[styles.chatReplayMessageTextAction] = isAction; + return classList; + })()} + > + +
{ + const classList: any = {}; + classList[styles.chatReplayMessageReply] = true; + classList[styles.chatReplayMessageTextAction] = replyData!.isAction; + return classList; + })()} + > + + Replying to {replyData!.user}: {replyData!.message} + +
+
+ {(item, index) => item} +
+ ); +}; diff --git a/thrimbletrimmer/src/common/convertTime.tsx b/thrimbletrimmer/src/common/convertTime.tsx index 7f8a183..f69f994 100644 --- a/thrimbletrimmer/src/common/convertTime.tsx +++ b/thrimbletrimmer/src/common/convertTime.tsx @@ -102,7 +102,10 @@ export function timeAgoFromDateTime(dateTime: DateTime): string { return `${negative}${hours}:${minutesString}:${secondsString}`; } -export function dateTimeFromVideoPlayerTime(fragments: Fragment[], videoTime: number): DateTime | null { +export function dateTimeFromVideoPlayerTime( + fragments: Fragment[], + videoTime: number, +): DateTime | null { let fragmentStartTime: number | undefined = undefined; let fragmentStartISOTime: string | undefined = undefined; for (const fragment of fragments) { diff --git a/thrimbletrimmer/src/restreamer/Restreamer.tsx b/thrimbletrimmer/src/restreamer/Restreamer.tsx index 28615bd..f764fd8 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.tsx +++ b/thrimbletrimmer/src/restreamer/Restreamer.tsx @@ -1,10 +1,10 @@ import { Accessor, Component, - createEffect, createResource, createSignal, For, + Index, onMount, Setter, Show, @@ -12,7 +12,6 @@ import { } from "solid-js"; import { DateTime } from "luxon"; import { Fragment } from "hls.js"; -import { HLSProvider } from "vidstack"; import { MediaPlayerElement } from "vidstack/elements"; import styles from "./Restreamer.module.scss"; import { @@ -22,7 +21,7 @@ import { } from "../common/convertTime"; import { StreamVideoInfo } from "../common/streamInfo"; import { KeyboardShortcuts, StreamTimeSettings, VideoPlayer } from "../common/video"; -import { chatData } from "../common/chat"; +import { chatData, ChatLog, ChatMessage, ChatMessageData, SystemMessage } from "../common/chat"; export interface DefaultsData { video_channel: string; @@ -162,14 +161,22 @@ const RestreamerWithDefaults: Component = (props) => { } return { streamInfo: streamInfo, - fragments: fragments + fragments: fragments, }; }; - const [chatMessages] = createResource(streamDataAndFragments, async () => { + const [possibleChatLog] = createResource(streamDataAndFragments, async () => { const { streamInfo, fragments } = streamDataAndFragments()!; return await chatData(streamInfo, fragments); }); + const chatLog = () => { + const chatLogData = possibleChatLog(); + if (chatLogData) { + return chatLogData; + } + return ChatLog.default(); + }; + return ( <> = (props) => { Download Video Download Current Frame as Image -
+
+ + + {(item: Accessor, index: number) => { + const chatCommand = item().message.command; + if (chatCommand === "PRIVMSG") { + return ( + + ); + } else if (chatCommand === "USERNOTICE") { + return ; + } else { + return <>; + } + }} + + +
); };