aboutsummaryrefslogtreecommitdiffstats
path: root/modules/ChatReplay
diff options
context:
space:
mode:
author0t4u <61939142+0t4u@users.noreply.github.com>2022-12-05 20:33:28 +0000
committerGitHub <noreply@github.com>2022-12-05 20:33:28 +0000
commitc6aa70a64731b608fade3acab48c1d1d2df280b6 (patch)
treea92d130d6b927e520ab40692bc504db7606f2092 /modules/ChatReplay
Add files via upload
Diffstat (limited to 'modules/ChatReplay')
-rw-r--r--modules/ChatReplay/ChatMessageRender.tsx171
-rw-r--r--modules/ChatReplay/ChatReplay.tsx74
-rw-r--r--modules/ChatReplay/ChatReplayPanel.tsx170
-rw-r--r--modules/ChatReplay/parser/default.ts21
-rw-r--r--modules/ChatReplay/parser/index.ts32
-rw-r--r--modules/ChatReplay/parser/yt-dlp.ts169
6 files changed, 637 insertions, 0 deletions
diff --git a/modules/ChatReplay/ChatMessageRender.tsx b/modules/ChatReplay/ChatMessageRender.tsx
new file mode 100644
index 0000000..1207457
--- /dev/null
+++ b/modules/ChatReplay/ChatMessageRender.tsx
@@ -0,0 +1,171 @@
+import React from "react";
+import { ChatMessage } from "../database.d";
+import { formatSeconds } from "../format";
+import { proxyYT3 as proxyURL } from "../util";
+
+export type ChatMessageRenderProps = {
+ message: ChatMessage;
+};
+
+const regexEmoji = /(:[^:]+:)/g;
+
+const ChatMessageRender = React.memo((props: ChatMessageRenderProps) => {
+ const msg = props.message;
+
+ const generateMessageContent = (msg: ChatMessage) => {
+ if (!msg.emotes) return msg.message;
+
+ // Process emotes
+ const tokens = msg.message.split(regexEmoji);
+ return tokens.map((token) => {
+ if (!token.startsWith(":")) return token;
+
+ let images = msg.emotes.find((emote) => emote.name === token)?.images;
+ if (!images)
+ images = msg.emotes.find((emote) => emote.shortcuts.includes(token))
+ ?.images;
+ if (!images) return token;
+
+ const emoteURL =
+ images.find((image) => image.id === "source")?.url || images[0]?.url;
+
+ if (!emoteURL) return token;
+ return (
+ <img
+ src={proxyURL(emoteURL)}
+ alt={token}
+ title={token}
+ className="inline-block w-6 h-6"
+ />
+ );
+ });
+ };
+
+ switch (msg.message_type) {
+ case "paid_message":
+ return (
+ <div
+ key={msg.message_id}
+ className="my-4 rounded overflow-hidden"
+ style={{
+ color: msg.body_text_colour,
+ background: msg.body_background_colour,
+ }}
+ >
+ <div
+ style={{
+ color: msg.header_text_colour,
+ background: msg.header_background_colour,
+ }}
+ className="flex flex-row justify-between px-4 py-2"
+ >
+ <div>
+ <div style={{ color: msg.author.name_text_colour }}>
+ {msg.author.name}
+ {msg.author.badges && msg.author.badges[0].icons ? (
+ <img
+ src={proxyURL(msg.author.badges[0].icons[1].url)}
+ alt={msg.author.badges[0].title}
+ title={msg.author.badges[0].title}
+ className="inline-block ml-2 w-4 h-4"
+ />
+ ) : null}
+ </div>
+ <div className="font-bold">{msg.money.text}</div>
+ </div>
+ <div
+ className="text-sm"
+ style={{ color: msg.author.name_text_colour }}
+ >
+ [{formatSeconds(msg.time_in_seconds)}]
+ </div>
+ </div>
+ <div className="px-4 py-2">{generateMessageContent(msg)}</div>
+ </div>
+ );
+ case "membership_item":
+ return (
+ <div
+ key={msg.message_id}
+ className="px-4 py-2 my-4 rounded bg-green-600 text-white"
+ >
+ <div className="flex flex-row justify-between pb-2">
+ <div className="font-bold">
+ <div className="inline-block">{msg.author.name}</div>
+ {msg.author.badges && msg.author.badges[0].icons ? (
+ <img
+ src={proxyURL(msg.author.badges[0].icons[1].url)}
+ alt={msg.author.badges[0].title}
+ title={msg.author.badges[0].title}
+ className="inline-block ml-2 w-4 h-4"
+ />
+ ) : null}
+ </div>
+ <div className="text-sm">
+ [{formatSeconds(msg.time_in_seconds)}]
+ </div>
+ </div>
+ {generateMessageContent(msg)}
+ </div>
+ );
+ case "text_message":
+ const authorType =
+ msg.author.badges?.map(({ title }) =>
+ title === "Owner"
+ ? "owner"
+ : title === "Moderator"
+ ? "moderator"
+ : title.toLowerCase().includes("member")
+ ? "member"
+ : ""
+ ) || [];
+ return (
+ <div key={msg.message_id} className="px-2 mb-2">
+ <div className="text-gray-400 text-xs flex justify-between">
+ <span
+ className={[
+ "mr-2",
+ authorType.includes("owner")
+ ? "bg-blue-600 text-white font-bold px-2 rounded"
+ : authorType.includes("moderator")
+ ? "text-blue-600 font-bold"
+ : authorType.includes("member")
+ ? "text-green-500"
+ : "",
+ ].join(" ")}
+ >
+ {msg.author.name}
+ {authorType.includes("moderator") && (
+ <svg
+ viewBox="0 0 16 16"
+ className="text-blue-600 w-4 h-4 inline-block ml-2"
+ >
+ <path
+ fill="currentColor"
+ d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z"
+ />
+ </svg>
+ )}
+ {msg.author.badges?.map((badge) =>
+ Array.isArray(badge.icons) ? (
+ <img
+ key={badge.title}
+ alt={badge.title}
+ title={badge.title}
+ src={proxyURL(badge.icons?.[1]?.url)}
+ className="inline-block ml-2 w-4 h-4"
+ />
+ ) : null
+ )}
+ </span>
+ <span>[{formatSeconds(msg.time_in_seconds)}]</span>
+ </div>
+ {generateMessageContent(msg)}
+ </div>
+ );
+ }
+
+ return null;
+});
+
+export default ChatMessageRender;
diff --git a/modules/ChatReplay/ChatReplay.tsx b/modules/ChatReplay/ChatReplay.tsx
new file mode 100644
index 0000000..b18b68b
--- /dev/null
+++ b/modules/ChatReplay/ChatReplay.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { ChatMessage } from "../database.d";
+import ChatMessageRender from "./ChatMessageRender";
+
+export type ChatReplayProps = {
+ replayData: ChatMessage[];
+ currentTimeSeconds: number;
+};
+
+function bsearch<T>(
+ arr: T[],
+ search: number,
+ transform: (item: T) => number
+): number {
+ let iL = 0,
+ iR = arr.length - 1,
+ iM = Math.floor(arr.length / 2);
+
+ while (iR - iL > 1) {
+ iM = Math.floor((iL + iR) / 2);
+ const m = transform(arr[iM]);
+
+ if (m < search) iL = iM;
+ else if (m > search) iR = iM;
+ else if (m === search || iL === iM) return iM;
+ }
+ return iM;
+}
+
+const ChatReplay = (props: ChatReplayProps) => {
+ const { replayData, currentTimeSeconds } = props;
+ const [messages, setMessages] = React.useState<ChatMessage[]>([]);
+
+ React.useEffect(() => {
+ /**
+ * Find the current replayData index where
+ * time_in_seconds <= currentTimeSeconds
+ * using binary search
+ */
+ const index = bsearch(
+ replayData,
+ currentTimeSeconds,
+ (message) => message.time_in_seconds
+ );
+
+ /**
+ * Once index is acquired, slice the array
+ * to get the previous `maxMessages` items
+ */
+ const maxMessages = 50;
+ const messages = replayData.slice(Math.max(0, index - maxMessages), index);
+
+ // Make sure there are no duplicate messages
+ const filtered = [];
+ const messageIds = [];
+
+ for (const message of messages) {
+ if (messageIds.includes(message.message_id)) continue;
+ messageIds.push(message.message_id);
+ filtered.push(message);
+ }
+
+ setMessages(filtered);
+ }, [replayData, currentTimeSeconds]);
+ return (
+ <div className="w-full break-words">
+ {messages.map((msg) => (
+ <ChatMessageRender key={msg.message_id} message={msg} />
+ ))}
+ </div>
+ );
+};
+
+export default ChatReplay;
diff --git a/modules/ChatReplay/ChatReplayPanel.tsx b/modules/ChatReplay/ChatReplayPanel.tsx
new file mode 100644
index 0000000..b5cfa7c
--- /dev/null
+++ b/modules/ChatReplay/ChatReplayPanel.tsx
@@ -0,0 +1,170 @@
+import React from "react";
+import axios from "axios";
+import { ChatMessage } from "../database.d";
+import ChatReplay from "./ChatReplay";
+import { IconChevronDown, IconFilter } from "../icons";
+import { useDebounce } from "../hooks/useDebounce";
+import { parseChatReplay } from "./parser";
+
+export type ChatReplayPanelProps = {
+ src: string;
+ currentTimeSeconds: number;
+ onChatToggle?: (isVisible: boolean) => any;
+};
+
+const ChatReplayPanel = (props: ChatReplayPanelProps) => {
+ const [replayData, setReplayData] = React.useState<ChatMessage[]>(null);
+ const [filteredReplayData, setFilteredReplayData] = React.useState<
+ ChatMessage[]
+ >(null);
+ const [downloadProgress, setDownloadProgress] = React.useState(-1);
+ const [isFilterVisible, setIsFilterVisible] = React.useState(false);
+ const [chatFilter, setChatFilter] = React.useState("");
+ const [isChatVisible, setIsChatVisible] = React.useState(false);
+ const [isErrored, setIsErrored] = React.useState(false);
+ const refChatScrollDiv = React.useRef<HTMLDivElement>(null);
+
+ const activeChatFilter = useDebounce(chatFilter, 250);
+
+ const downloadChatData = async () => {
+ setDownloadProgress(0);
+ setReplayData(null);
+ setIsErrored(false);
+ try {
+ const data = await axios.get(props.src, {
+ onDownloadProgress: ({ loaded }) => setDownloadProgress(loaded),
+ transformResponse: (res) => res,
+ });
+
+ setReplayData(parseChatReplay(data.data));
+ setIsChatVisible(true);
+ } catch (ex) {
+ console.log("[chat] error parsing chat:", ex);
+ setIsErrored(true);
+ }
+ };
+
+ React.useEffect(() => {
+ if (!replayData) return;
+ if (!activeChatFilter) return setFilteredReplayData(replayData);
+
+ setFilteredReplayData(
+ replayData.filter(
+ (message) =>
+ !!message.message &&
+ message.message.toLowerCase().includes(activeChatFilter.toLowerCase())
+ )
+ );
+ }, [activeChatFilter, replayData]);
+
+ /**
+ * Automatically download chat replay
+ */
+ React.useEffect(() => {
+ downloadChatData();
+ }, [props.src]);
+
+ React.useEffect(() => {
+ if (refChatScrollDiv.current)
+ refChatScrollDiv.current.scrollTo({
+ top: refChatScrollDiv.current.scrollHeight,
+ behavior: "smooth",
+ });
+ }, [props.currentTimeSeconds]);
+
+ React.useEffect(() => {
+ props.onChatToggle?.(isChatVisible);
+ }, [isChatVisible]);
+
+ if (replayData === null)
+ return (
+ <div
+ className="border border-gray-800 rounded p-4 text-center cursor-pointer"
+ onClick={() => {
+ if (isErrored || downloadProgress < 0) downloadChatData();
+ }}
+ >
+ {isErrored ? (
+ <p>Error loading chat, click to retry</p>
+ ) : downloadProgress < 0 ? (
+ <>
+ <p>Chat replay available!</p>
+ <p>Click to Enable</p>
+ </>
+ ) : downloadProgress === 0 ? (
+ <p>Loading chat replay...</p>
+ ) : (
+ <p>Loaded {(downloadProgress / 1024 / 1024).toFixed(2)}MB</p>
+ )}
+ </div>
+ );
+
+ return (
+ <div className="h-full flex flex-col">
+ <div className="flex flex-col text-white bg-gray-900">
+ <div className="flex">
+ <div className="flex-1 flex items-center px-4">Chat replay</div>
+ <div>
+ <button
+ type="button"
+ onClick={() => setIsFilterVisible((now) => !now)}
+ className="px-4 py-2"
+ >
+ <IconFilter width="1em" height="1em" />
+ </button>
+ <button
+ type="button"
+ onClick={() => setIsChatVisible((now) => !now)}
+ className="text-lg px-4 py-2"
+ >
+ <IconChevronDown
+ width="1em"
+ height="1em"
+ style={{ transform: isChatVisible ? "rotate(180deg)" : "" }}
+ />
+ </button>
+ </div>
+ </div>
+ {isFilterVisible && (
+ <div className="flex flex-col">
+ <div className="flex">
+ <input
+ type="text"
+ placeholder="Filter text"
+ className="
+ w-full rounded px-4 py-1 md:mx-2
+ bg-gray-800 hover:bg-gray-700 focus:outline-none focus:ring
+ transition duration-100 z-20
+ "
+ value={chatFilter}
+ onChange={(e) => setChatFilter(e.target.value)}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ {isChatVisible && (
+ <div className="relative flex-1">
+ <div
+ className={[
+ "px-2 border border-gray-800 rounded",
+ "overflow-y-scroll absolute inset-0",
+ "transition-all duration-200",
+ ].join(" ")}
+ style={{
+ overscrollBehavior: "contain",
+ }}
+ ref={refChatScrollDiv}
+ >
+ <ChatReplay
+ currentTimeSeconds={props.currentTimeSeconds}
+ replayData={filteredReplayData}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default ChatReplayPanel;
diff --git a/modules/ChatReplay/parser/default.ts b/modules/ChatReplay/parser/default.ts
new file mode 100644
index 0000000..acf1aef
--- /dev/null
+++ b/modules/ChatReplay/parser/default.ts
@@ -0,0 +1,21 @@
+import { ChatReplayParser } from ".";
+import { ChatMessage } from "../../database.d";
+
+export default class DefaultChatParser implements ChatReplayParser {
+ name: "DefaultChatParser";
+ chatData: string = "";
+
+ constructor(chatData: string) {
+ this.chatData = chatData.trim();
+ }
+
+ canParse(): boolean {
+ return this.chatData.startsWith("[") && this.chatData.endsWith("]");
+ }
+
+ parse(): ChatMessage[] {
+ return (JSON.parse(this.chatData) as ChatMessage[]).sort(
+ (a, b) => a.time_in_seconds - b.time_in_seconds
+ );
+ }
+}
diff --git a/modules/ChatReplay/parser/index.ts b/modules/ChatReplay/parser/index.ts
new file mode 100644
index 0000000..d921603
--- /dev/null
+++ b/modules/ChatReplay/parser/index.ts
@@ -0,0 +1,32 @@
+import { ChatMessage } from "../../database.d";
+import DefaultChatParser from "./default";
+import YtDlpChatParser from "./yt-dlp";
+
+export abstract class ChatReplayParser {
+ name: string = "";
+
+ constructor(chatData: string) {
+ if (this.constructor === ChatReplayParser)
+ throw new Error("Abstract classes can't be instantiated.");
+ }
+
+ canParse(): boolean {
+ return false;
+ }
+
+ parse(): ChatMessage[] {
+ return [];
+ }
+}
+
+export const parseChatReplay = (input: string) => {
+ console.log("[chat] parsing chat data");
+ const parser = [DefaultChatParser, YtDlpChatParser]
+ .map((Parser) => new Parser(input))
+ .find((parser) => parser.canParse());
+ if (!parser) throw new Error("No suitable chat parser found");
+ console.log("[chat] using", parser.name);
+ const parsed = parser.parse();
+ console.log("[chat] found", parsed.length, "messages");
+ return parsed;
+};
diff --git a/modules/ChatReplay/parser/yt-dlp.ts b/modules/ChatReplay/parser/yt-dlp.ts
new file mode 100644
index 0000000..0647961
--- /dev/null
+++ b/modules/ChatReplay/parser/yt-dlp.ts
@@ -0,0 +1,169 @@
+import { ChatReplayParser } from ".";
+import {
+ ChatMessage,
+ ChatMessageAuthor,
+ ChatMessageImage,
+} from "../../database.d";
+
+export default class YtDlpChatParser implements ChatReplayParser {
+ name = "YtDlpChatParser";
+ chatData: string = "";
+
+ constructor(chatData: string) {
+ this.chatData = chatData.trim();
+ }
+
+ canParse(): boolean {
+ return (
+ this.chatData.startsWith("{") &&
+ this.chatData.endsWith("}") &&
+ this.chatData.includes("\n") &&
+ this.chatData.includes("clickTrackingParams")
+ );
+ }
+
+ _formatMsec(ms: number): string {
+ const secs = Math.abs(Math.floor(ms / 1000)),
+ ss = secs % 60,
+ mm = Math.floor(secs / 60),
+ hh = Math.floor(secs / 3600);
+ return (
+ (ms < 0 ? "-" : "") +
+ (hh > 0 ? [hh, mm, ss] : [mm, ss])
+ .map((x) => x.toString().padStart(2, "0"))
+ .join(":")
+ );
+ }
+
+ parse(): ChatMessage[] {
+ return this.chatData
+ .split("\n")
+ .map((line) => {
+ try {
+ return JSON.parse(line);
+ } catch (ex) {
+ return null;
+ }
+ })
+ .filter(Boolean)
+ .map((event) => {
+ try {
+ const actionBase = event.replayChatItemAction.actions[0];
+ const action_type =
+ "addChatItemAction" in actionBase
+ ? "add_chat_item"
+ : "addLiveChatTickerItemAction" in actionBase
+ ? "add_live_chat_ticker_item"
+ : "unknown";
+
+ // Skip handling tickers for now
+ if (action_type !== "add_chat_item") return null;
+
+ const actionItem = actionBase.addChatItemAction.item;
+ const message_type =
+ "liveChatMembershipItemRenderer" in actionItem
+ ? "membership_item"
+ : "liveChatTextMessageRenderer" in actionItem
+ ? "text_message"
+ : "liveChatPaidMessageRenderer" in actionItem
+ ? "paid_message"
+ : "unknown";
+
+ if (message_type === "unknown") return null;
+
+ const messageItem =
+ actionItem.liveChatMembershipItemRenderer ||
+ actionItem.liveChatTextMessageRenderer ||
+ actionItem.liveChatPaidMessageRenderer;
+
+ if (!messageItem) return null;
+
+ const author: ChatMessageAuthor & {
+ name_text_colour?: string;
+ } = {
+ name: messageItem.authorName?.simpleText || "",
+ id: messageItem.authorExternalChannelId,
+ images: messageItem.authorPhoto?.thumbnails.map(
+ (thumb: Partial<ChatMessageImage>) => ({
+ id: String(thumb.height),
+ ...thumb,
+ })
+ ),
+ badges: messageItem.authorBadges?.map(
+ ({ liveChatAuthorBadgeRenderer: badge }: any) => ({
+ title: badge.tooltip,
+ icons: badge.customThumbnail?.thumbnails,
+ })
+ ),
+ name_text_colour:
+ "#" + messageItem.authorNameTextColor?.toString(16).substr(2),
+ };
+
+ const timeMsec = Number(
+ event.replayChatItemAction.videoOffsetTimeMsec ||
+ event.videoOffsetTimeMsec
+ );
+
+ return {
+ time_in_seconds: timeMsec / 1000,
+ action_type,
+ message_type,
+ author,
+ message_id: messageItem.id,
+ timestamp: Number(messageItem.timestampUsec),
+ time_text:
+ messageItem.timestampText?.simpleText ||
+ this._formatMsec(timeMsec),
+ message:
+ messageItem.message || messageItem.headerSubtext
+ ? (messageItem.message || messageItem.headerSubtext).runs
+ .map((run: any) => run?.emoji?.shortcuts?.[0] ?? run.text)
+ .join("")
+ : "",
+ emotes: messageItem.message?.runs
+ .map((run: any) => run.emoji)
+ .filter((e: any) => Boolean(e?.shortcuts))
+ .map((e: any) => ({
+ id: e.emojiId,
+ name: e.shortcuts[0],
+ shortcuts: e.shortcuts,
+ search_terms: e.searchTerms,
+ is_custom_emoji: e.isCustomEmoji,
+ images: e.image?.thumbnails.map(
+ (thumb: Partial<ChatMessageImage>) => ({
+ id: String(thumb.height),
+ ...thumb,
+ })
+ ),
+ })),
+ ...(message_type === "paid_message"
+ ? {
+ money: {
+ text: messageItem.purchaseAmountText.simpleText,
+ amount: 0,
+ currency: "-",
+ currency_symbol: "-",
+ },
+ timestamp_colour:
+ "#" + messageItem.timestampColor?.toString(16).substr(2),
+ body_background_colour:
+ "#" +
+ messageItem.bodyBackgroundColor?.toString(16).substr(2),
+ header_text_colour:
+ "#" + messageItem.headerTextColor?.toString(16).substr(2),
+ header_background_colour:
+ "#" +
+ messageItem.headerBackgroundColor?.toString(16).substr(2),
+ body_text_colour:
+ "#" + messageItem.bodyTextColor?.toString(16).substr(2),
+ }
+ : {}),
+ };
+ } catch (ex) {
+ return null;
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => a.time_in_seconds - b.time_in_seconds) as ChatMessage[];
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage