mirror of https://github.com/ekimekim/wubloader
Compare commits
No commits in common. '92a8d1353b4e6fe93e329e338d21e9b4d3f52c10' and '8df629a1be00083aa8a0e3f6a9ec4643512da19c' have entirely different histories.
92a8d1353b
...
8df629a1be
@ -1,50 +0,0 @@
|
||||
.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,425 +0,0 @@
|
||||
import { Accessor, Component, createResource, For, Index, JSX, Show, Suspense } from "solid-js";
|
||||
import { StreamVideoInfo } from "./streamInfo";
|
||||
import { DateTime } from "luxon";
|
||||
import { Fragment } from "hls.js";
|
||||
import { wubloaderTimeFromDateTime } from "./convertTime";
|
||||
import styles from "./chat.module.scss";
|
||||
|
||||
export interface RawChatMessage {
|
||||
command: string;
|
||||
host: string;
|
||||
params: string[];
|
||||
receivers: { [node: string]: number };
|
||||
sender: string;
|
||||
tags: { [key: string]: string };
|
||||
time: number;
|
||||
time_range: number;
|
||||
user: string;
|
||||
}
|
||||
|
||||
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<ChatLog> {
|
||||
const streamName = streamInfo.streamName;
|
||||
const streamStartTime = streamInfo.streamStartTime;
|
||||
const streamEndTime = streamInfo.streamEndTime;
|
||||
if (!streamEndTime) {
|
||||
return ChatLog.default();
|
||||
}
|
||||
const startWubloaderTime = wubloaderTimeFromDateTime(streamStartTime);
|
||||
const endWubloaderTime = wubloaderTimeFromDateTime(streamEndTime);
|
||||
const params = new URLSearchParams({ start: startWubloaderTime, end: endWubloaderTime });
|
||||
|
||||
const chatResponse = await fetch(`/${streamName}/chat.json?${params}`);
|
||||
if (!chatResponse.ok) {
|
||||
return ChatLog.default();
|
||||
}
|
||||
const chatMessages: RawChatMessage[] = await chatResponse.json();
|
||||
|
||||
if (!fragments || fragments.length === 0) {
|
||||
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) {
|
||||
const when = DateTime.fromSeconds(chatMessage.time);
|
||||
while (
|
||||
currentFragmentIndex < fragments.length - 1 &&
|
||||
currentFragmentStartTime.plus({ seconds: fragments[currentFragmentIndex].duration }) <= when
|
||||
) {
|
||||
currentFragmentIndex += 1;
|
||||
currentFragmentStartTime = DateTime.fromISO(
|
||||
fragments[currentFragmentIndex].rawProgramDateTime!,
|
||||
);
|
||||
}
|
||||
const messageTimeOffset = when.diff(currentFragmentStartTime).milliseconds / 1000;
|
||||
const messageVideoTime = fragments[currentFragmentIndex].start + messageTimeOffset;
|
||||
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 new ChatLog(chatData, clearMessages, clearUsers);
|
||||
}
|
||||
|
||||
function formatDisplayTime(timeSeconds: number): string {
|
||||
const hours = Math.floor(timeSeconds / 3600);
|
||||
const minutes = (Math.floor(timeSeconds / 60) % 60).toString().padStart(2, "0");
|
||||
const seconds = Math.floor(timeSeconds % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const milliseconds = Math.floor((timeSeconds % 1) * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0");
|
||||
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
export interface ChatDisplayProps {
|
||||
streamInfo: StreamVideoInfo;
|
||||
fragments: Accessor<Fragment[]>;
|
||||
videoTime: Accessor<number>;
|
||||
}
|
||||
|
||||
export const ChatDisplay: Component<ChatDisplayProps> = (props) => {
|
||||
const streamDataAndFragments = () => {
|
||||
const fragments = props.fragments();
|
||||
if (!fragments || fragments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
streamInfo: props.streamInfo,
|
||||
fragments: fragments,
|
||||
};
|
||||
};
|
||||
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 (
|
||||
<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={props.videoTime} />
|
||||
);
|
||||
} else if (chatCommand === "USERNOTICE") {
|
||||
return <SystemMessage chatMessage={item()} videoTime={props.videoTime} />;
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}}
|
||||
</Index>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export class StreamVideoInfo {
|
||||
streamName: string;
|
||||
streamStartTime: DateTime;
|
||||
streamEndTime: DateTime | null;
|
||||
}
|
Loading…
Reference in New Issue