Chat replay

thrimbletrimmer-solid
ElementalAlchemist 2 weeks ago
parent 14690ee406
commit bd866ba1bf

@ -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;
}
}

@ -1,10 +1,11 @@
import { Accessor, Component, For, JSX, Show } from "solid-js";
import { StreamVideoInfo } from "./streamInfo"; import { StreamVideoInfo } from "./streamInfo";
import { HLSProvider } from "vidstack";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Fragment } from "hls.js"; import { Fragment } from "hls.js";
import { wubloaderTimeFromDateTime } from "./convertTime"; import { wubloaderTimeFromDateTime } from "./convertTime";
import styles from "./chat.module.scss";
export interface ChatMessage { export interface RawChatMessage {
command: string; command: string;
host: string; host: string;
params: string[]; params: string[];
@ -16,22 +17,59 @@ export interface ChatMessage {
user: string; user: string;
} }
export interface ChatMessageData { export class ChatMessageData {
message: ChatMessage; message: RawChatMessage;
messageID: string;
userID: string | null;
when: DateTime; when: DateTime;
whenSeconds: number; whenSeconds: number;
whenDisplay: string; 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( export async function chatData(
streamInfo: StreamVideoInfo, streamInfo: StreamVideoInfo,
fragments: Fragment[], fragments: Fragment[],
): Promise<ChatMessageData[]> { ): Promise<ChatLog> {
const streamName = streamInfo.streamName; const streamName = streamInfo.streamName;
const streamStartTime = streamInfo.streamStartTime; const streamStartTime = streamInfo.streamStartTime;
const streamEndTime = streamInfo.streamEndTime; const streamEndTime = streamInfo.streamEndTime;
if (!streamEndTime) { if (!streamEndTime) {
return []; return ChatLog.default();
} }
const startWubloaderTime = wubloaderTimeFromDateTime(streamStartTime); const startWubloaderTime = wubloaderTimeFromDateTime(streamStartTime);
const endWubloaderTime = wubloaderTimeFromDateTime(streamEndTime); const endWubloaderTime = wubloaderTimeFromDateTime(streamEndTime);
@ -39,26 +77,20 @@ export async function chatData(
const chatResponse = await fetch(`/${streamName}/chat.json?${params}`); const chatResponse = await fetch(`/${streamName}/chat.json?${params}`);
if (!chatResponse.ok) { if (!chatResponse.ok) {
return []; return ChatLog.default();
} }
const chatMessages: ChatMessage[] = await chatResponse.json(); const chatMessages: RawChatMessage[] = await chatResponse.json();
if (!fragments || fragments.length === 0) { if (!fragments || fragments.length === 0) {
return []; return ChatLog.default();
} }
let currentFragmentIndex = 0; let currentFragmentIndex = 0;
let currentFragmentStartTime = DateTime.fromISO(fragments[0].rawProgramDateTime!)!; let currentFragmentStartTime = DateTime.fromISO(fragments[0].rawProgramDateTime!)!;
const chatData: ChatMessageData[] = []; const chatData: ChatMessageData[] = [];
const clearMessages: { [messageID: string]: number } = {};
const clearUsers: { [userID: string]: number[] } = {};
for (const chatMessage of chatMessages) { 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); const when = DateTime.fromSeconds(chatMessage.time);
while ( while (
currentFragmentIndex < fragments.length - 1 && currentFragmentIndex < fragments.length - 1 &&
@ -69,16 +101,38 @@ export async function chatData(
fragments[currentFragmentIndex].rawProgramDateTime!, fragments[currentFragmentIndex].rawProgramDateTime!,
); );
} }
const messageTimeOffset = when.diff(currentFragmentStartTime).seconds; const messageTimeOffset = when.diff(currentFragmentStartTime).milliseconds / 1000;
const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset; const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset;
chatData.push({ if (chatMessage.command === "PRIVMSG") {
message: chatMessage, chatData.push({
when: when, message: chatMessage,
whenSeconds: messageVideoTime, messageID: chatMessage.tags["id"],
whenDisplay: formatDisplayTime(messageVideoTime), 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 { function formatDisplayTime(timeSeconds: number): string {
@ -87,9 +141,235 @@ function formatDisplayTime(timeSeconds: number): string {
const seconds = Math.floor(timeSeconds % 60) const seconds = Math.floor(timeSeconds % 60)
.toString() .toString()
.padStart(2, "0"); .padStart(2, "0");
const milliseconds = Math.floor(timeSeconds * 1000) const milliseconds = Math.floor((timeSeconds % 1) * 1000)
.toString() .toString()
.padStart(3, "0"); .padStart(3, "0");
return `${hours}:${minutes}:${seconds}.${milliseconds}`; return `${hours}:${minutes}:${seconds}.${milliseconds}`;
} }
export interface ChatMessageProps {
chatMessage: ChatMessageData;
chatLog: ChatLog;
videoTime: Accessor<number>;
}
export const ChatMessage: Component<ChatMessageProps> = (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 (
<Show when={displayChatMessage()}>
<div
id={`chat-replay-message-${message.message.tags.id}`}
classList={(() => {
const classList: any = {};
classList[styles.chatReplayMessage] = true;
classList[styles.chatReplayMessageCleared] = clearMessageCheck();
return classList;
})()}
>
<div class={styles.chatReplayMessageTime}>{message.whenDisplay}</div>
<MessageSender chatMessage={message} />
<MessageText chatMessage={message} />
</div>
</Show>
);
};
export interface SystemMessageProps {
chatMessage: ChatMessageData;
videoTime: Accessor<number>;
}
export const SystemMessage: Component<SystemMessageProps> = (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 (
<Show when={displaySystemMessage()}>
<div
id={`chat-replay-message-system-${message.message.tags.id}`}
class={styles.chatReplayMessage}
>
<div class={styles.chatReplayMessageTime}>{message.whenDisplay}</div>
<MessageSender chatMessage={message} />
<div class={`${styles.chatReplayMessageText} ${styles.chatReplayMessageSystem}`}>
{systemMessage()}
</div>
</div>
<Show when={message.message.params.length > 1}>
<div id={`chat-replay-message-${message.message.tags.id}`}>
<div class={styles.chatReplayMessageTime}></div>
<MessageSender chatMessage={message} />
<MessageText chatMessage={message} />
</div>
</Show>
</Show>
);
};
interface MessageSenderProps {
chatMessage: ChatMessageData;
}
const MessageSender: Component<MessageSenderProps> = (props) => {
const message = props.chatMessage.message;
const color = message.tags.hasOwnProperty("color") ? message.tags.color : "inherit";
return (
<div class={styles.chatReplayMessageSender} style={`color: ${color}`}>
{message.tags["display-name"]}
</div>
);
};
interface MessageTextProps {
chatMessage: ChatMessageData;
}
const MessageText: Component<MessageTextProps> = (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(
<img
src={emoteImageURL}
alt={emoteText}
title={emoteText}
class={styles.chatReplayMessageEmote}
/>,
);
}
if (chatMessageText !== "") {
messageParts.push(<>{chatMessageText}</>);
}
} else {
messageParts.push(<>{chatMessageText}</>);
}
return (
<div
classList={(() => {
const classList: { [className: string]: boolean } = {};
classList[styles.chatReplayMessageText] = true;
classList[styles.chatReplayMessageTextAction] = isAction;
return classList;
})()}
>
<Show when={replyData}>
<div
classList={(() => {
const classList: any = {};
classList[styles.chatReplayMessageReply] = true;
classList[styles.chatReplayMessageTextAction] = replyData!.isAction;
return classList;
})()}
>
<a href={`#chat-replay-message-${replyData!.id}`}>
Replying to {replyData!.user}: {replyData!.message}
</a>
</div>
</Show>
<For each={messageParts}>{(item, index) => item}</For>
</div>
);
};

@ -102,7 +102,10 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
return `${negative}${hours}:${minutesString}:${secondsString}`; 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 fragmentStartTime: number | undefined = undefined;
let fragmentStartISOTime: string | undefined = undefined; let fragmentStartISOTime: string | undefined = undefined;
for (const fragment of fragments) { for (const fragment of fragments) {

@ -1,10 +1,10 @@
import { import {
Accessor, Accessor,
Component, Component,
createEffect,
createResource, createResource,
createSignal, createSignal,
For, For,
Index,
onMount, onMount,
Setter, Setter,
Show, Show,
@ -12,7 +12,6 @@ import {
} from "solid-js"; } from "solid-js";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Fragment } from "hls.js"; import { Fragment } from "hls.js";
import { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import styles from "./Restreamer.module.scss"; import styles from "./Restreamer.module.scss";
import { import {
@ -22,7 +21,7 @@ import {
} from "../common/convertTime"; } from "../common/convertTime";
import { StreamVideoInfo } from "../common/streamInfo"; import { StreamVideoInfo } from "../common/streamInfo";
import { KeyboardShortcuts, StreamTimeSettings, VideoPlayer } from "../common/video"; import { KeyboardShortcuts, StreamTimeSettings, VideoPlayer } from "../common/video";
import { chatData } from "../common/chat"; import { chatData, ChatLog, ChatMessage, ChatMessageData, SystemMessage } from "../common/chat";
export interface DefaultsData { export interface DefaultsData {
video_channel: string; video_channel: string;
@ -162,14 +161,22 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
} }
return { return {
streamInfo: streamInfo, streamInfo: streamInfo,
fragments: fragments fragments: fragments,
}; };
}; };
const [chatMessages] = createResource(streamDataAndFragments, async () => { const [possibleChatLog] = createResource(streamDataAndFragments, async () => {
const { streamInfo, fragments } = streamDataAndFragments()!; const { streamInfo, fragments } = streamDataAndFragments()!;
return await chatData(streamInfo, fragments); return await chatData(streamInfo, fragments);
}); });
const chatLog = () => {
const chatLogData = possibleChatLog();
if (chatLogData) {
return chatLogData;
}
return ChatLog.default();
};
return ( return (
<> <>
<StreamTimeSettings <StreamTimeSettings
@ -190,7 +197,24 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
<a href={downloadVideoURL()}>Download Video</a> <a href={downloadVideoURL()}>Download Video</a>
<a href={downloadFrameURL()}>Download Current Frame as Image</a> <a href={downloadFrameURL()}>Download Current Frame as Image</a>
</div> </div>
<div class={styles.chatContainer}></div> <div class={styles.chatContainer}>
<Suspense>
<Index each={chatLog().messages}>
{(item: Accessor<ChatMessageData>, index: number) => {
const chatCommand = item().message.command;
if (chatCommand === "PRIVMSG") {
return (
<ChatMessage chatMessage={item()} chatLog={chatLog()} videoTime={playerTime} />
);
} else if (chatCommand === "USERNOTICE") {
return <SystemMessage chatMessage={item()} videoTime={playerTime} />;
} else {
return <></>;
}
}}
</Index>
</Suspense>
</div>
</> </>
); );
}; };

Loading…
Cancel
Save