aboutsummaryrefslogtreecommitdiffstats
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-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
-rw-r--r--modules/NextImage.tsx7
-rw-r--r--modules/PageBase.tsx48
-rw-r--r--modules/VideoActionButtons.tsx144
-rw-r--r--modules/VideoPlayer/MediaSync.tsx194
-rw-r--r--modules/VideoPlayer/SeekBar.tsx88
-rw-r--r--modules/VideoPlayer/VideoPlayer2.tsx558
-rw-r--r--modules/VideoPlayer/components/LoaderRing.tsx10
-rw-r--r--modules/database.d.ts170
-rw-r--r--modules/format.ts24
-rw-r--r--modules/hooks/useAnimationFrame.tsx30
-rw-r--r--modules/hooks/useDebounce.tsx25
-rw-r--r--modules/hooks/useLocalStorage.tsx48
-rw-r--r--modules/hooks/useThrottle.tsx24
-rw-r--r--modules/hooks/useWindowSize.tsx19
-rw-r--r--modules/icons.tsx219
-rw-r--r--modules/shared/DefaultHead.tsx39
-rw-r--r--modules/util.ts25
23 files changed, 2309 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[];
+ }
+}
diff --git a/modules/NextImage.tsx b/modules/NextImage.tsx
new file mode 100644
index 0000000..e27c0f3
--- /dev/null
+++ b/modules/NextImage.tsx
@@ -0,0 +1,7 @@
+import Image from "next/image";
+
+type NextImageProps = React.ComponentPropsWithoutRef<typeof Image>;
+
+export const NextImage = (props: NextImageProps) => (
+ <Image unoptimized={false} {...props} />
+);
diff --git a/modules/PageBase.tsx b/modules/PageBase.tsx
new file mode 100644
index 0000000..f6290c3
--- /dev/null
+++ b/modules/PageBase.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import Head from 'next/head';
+
+export type PageBaseProps = {
+ children?: React.ReactNode;
+ flex?: boolean;
+};
+
+const PageBase = (props: PageBaseProps) => {
+ return (
+ <div className="bg-black text-white flex flex-col flex-1">
+ <Head>
+ <link rel="shortcut icon" href="/favicon.png" type="image/png" />
+ </Head> <div
+ className={[
+ 'container mx-auto mt-4 flex-1',
+ props.flex ? 'flex flex-col' : '',
+ ].join(' ')}
+ >
+ {props.children}
+ </div>
+ {/* <div className="mt-6 text-gray-500 text-center">
+ Made with 🍝 by{' '}
+ <a
+ href="https://twitter.com/kitsune_cw"
+ className="hover:underline"
+ target="_blank"
+ rel="noreferrer noopener nofollow"
+ >
+ kitsune
+ </a>
+ .
+ </div>
+ <div className="mb-6 text-center">
+ <a
+ href="https://gitlab.com/aonahara/archive-browser"
+ className="text-gray-500 hover:underline"
+ target="_blank"
+ rel="noreferrer noopener nofollow"
+ >
+ Source code
+ </a>
+ </div> */}
+ </div>
+ );
+};
+
+export default PageBase;
diff --git a/modules/VideoActionButtons.tsx b/modules/VideoActionButtons.tsx
new file mode 100644
index 0000000..572ded2
--- /dev/null
+++ b/modules/VideoActionButtons.tsx
@@ -0,0 +1,144 @@
+import Link from "next/link";
+import React from "react";
+import { VideoMetadata } from "./database.d";
+import { formatBytes } from "./format";
+import {
+ IconChartBar,
+ IconChevronDown,
+ IconDownload,
+ IconYouTube,
+} from "./icons";
+
+type VideoActionButtonsProps = {
+ video: VideoMetadata;
+ full?: boolean;
+};
+
+export const buttonStyle = `
+ bg-gray-800
+ hover:bg-gray-700
+ focus:bg-gray-900 focus:outline-none
+ px-4 py-2 mb-2 md:mb-0 rounded
+ transition duration-200
+ flex flex-row items-center`.replace(/\s+/, " ");
+
+const getFile = (videoInfo: VideoMetadata, suffix: string) =>
+ videoInfo.files.find((file) => file.name.endsWith(suffix));
+
+const VideoActionButtons = React.memo(
+ ({ video, full }: VideoActionButtonsProps) => {
+ const [isMenuOpen, setIsMenuOpen] = React.useState(false);
+
+ const mkv = getFile(video, ".mkv");
+ const mkvURL = mkv?.url;
+ const mkvSize = mkv?.size || -1;
+
+ const [fmtVideo, fmtAudio] = video.format_id.split("+");
+
+ const handleToggleMenu = (
+ e: React.MouseEvent<HTMLAnchorElement, MouseEvent>
+ ) => {
+ e.preventDefault();
+ setIsMenuOpen((now) => !now);
+ };
+
+ const fileURLs = video.files
+ ?.filter(({ name }) => !name.endsWith(".mkv"))
+ .map(({ name, size, url }) => ({
+ label: name.includes(".f" + fmtVideo + ".")
+ ? "Video only"
+ : name.includes(".f" + fmtAudio + ".")
+ ? "Audio only"
+ : name.endsWith(".vtt")
+ ? "Captions (vtt, " + name.split(".")[1] + ")"
+ : name.endsWith(".ytt")
+ ? "Captions (srv3, " + name.split(".")[1] + ")"
+ : name.endsWith(".chat.json")
+ ? "Chat logs (json)"
+ : name.endsWith(".info.json")
+ ? "Metadata (json)"
+ : name.endsWith(".webp")
+ ? "Thumbnail (webp)"
+ : name.endsWith(".jpg")
+ ? "Thumbnail (jpeg)"
+ : name,
+ name,
+ size,
+ url,
+ }))
+ .sort((a, b) => (a.label > b.label ? 1 : -1));
+
+ // Account for aspect ratio wider than 16:9
+ const videoHeight = Math.max(video.height, 0.5625 * video.width);
+
+ return (
+ <>
+ {isMenuOpen && (
+ <div
+ className="fixed inset-0 opacity-0"
+ onClick={() => setIsMenuOpen(false)}
+ />
+ )}
+ <div className="relative flex md:flex-row flex-col w-full mt-2">
+ <a
+ href={mkvURL}
+ className={buttonStyle}
+ >
+ <IconDownload className="w-4 h-4 mr-3" />
+ Download ({videoHeight}p{video.fps}, {formatBytes(mkvSize)})
+ </a>
+ <a
+ href="#"
+ className={[buttonStyle, "md:mr-2"].join(" ")}
+ onClick={handleToggleMenu}
+ aria-label="More download options"
+ >
+ <IconChevronDown className="w-4 h-4" />
+ <span className="md:hidden ml-3">More download options</span>
+ </a>
+ {isMenuOpen && (
+ <div className="absolute z-10 left-0 top-10 bg-gray-800 rounded overflow-hidden">
+ {fileURLs.map((file) => (
+ <a
+ key={file.name}
+ href={file.url}
+ target="_blank"
+ rel="noreferrer noopener nofollow"
+ className="hover:bg-gray-700 focus:bg-gray-900 focus:outline-none block px-4 py-2 transition duration-200"
+ >
+ {file.label}
+ </a>
+ ))}
+ </div>
+ )}
+ <a
+ href={"https://youtu.be/" + video.video_id}
+ target="_blank"
+ rel="noreferrer noopener"
+ className={[buttonStyle, "md:mr-2"].join(" ")}
+ >
+ <IconYouTube className="w-4 h-4 mr-3" />
+ Watch on YouTube
+ </a>
+ {full && (
+ <>
+ <div className="flex-1" />
+ {video.files.findIndex((file) =>
+ file.name.endsWith(".chat.json")
+ ) > -1 && (
+ <Link href={"/tools/chat-explorer?v=" + video.video_id}>
+ <a className={buttonStyle}>
+ <IconChartBar className="w-4 h-4" />
+ <span className="ml-3">Chat explorer</span>
+ </a>
+ </Link>
+ )}
+ </>
+ )}
+ </div>
+ </>
+ );
+ }
+);
+
+export default VideoActionButtons;
diff --git a/modules/VideoPlayer/MediaSync.tsx b/modules/VideoPlayer/MediaSync.tsx
new file mode 100644
index 0000000..7c3d811
--- /dev/null
+++ b/modules/VideoPlayer/MediaSync.tsx
@@ -0,0 +1,194 @@
+import React, { forwardRef, Ref, useImperativeHandle } from "react";
+import { useAnimationFrame } from "../hooks/useAnimationFrame";
+
+export type MediaSyncState = {
+ duration: number;
+ timeSeconds: number;
+ isPlaying: boolean;
+ isSynced: boolean;
+ isStalled: boolean;
+};
+
+export type MediaSyncProps = {
+ children?: React.ReactNode;
+
+ // Events
+ onStateUpdate?: (state: MediaSyncState) => any;
+ onPlay?: () => any;
+ onPause?: () => any;
+};
+
+export type MediaSyncRef = {
+ play: () => void;
+ pause: () => void;
+ seek: (timeSeconds: number) => void;
+};
+
+const MediaSync = forwardRef<MediaSyncRef, MediaSyncProps>((props, ref) => {
+ // Create refs for all media elements
+ const childCount = React.Children.count(props.children);
+ const childRefs = Array(childCount)
+ .fill(null)
+ .map(() => React.useRef<HTMLMediaElement>(null));
+
+ // Playback state
+ const isPlaying = React.useRef(false);
+ const syncStates = React.useRef(Array(childCount).fill(false));
+ const lastVisibilityState = React.useRef<"visible" | "hidden">("visible");
+ const lastTime = React.useRef(0);
+ const lastTimeUpdate = React.useRef(Date.now());
+
+ // Settings
+ // Trigger sync at 15fps
+ // i.e. media is considered out of sync if time difference > 1/15 second
+ const syncThreshold = React.useRef(1 / 15);
+ // Release sync at 60fps
+ // i.e. media is considered in sync if time difference < 1/60 second
+ const syncedThreshold = React.useRef(1 / 60);
+ // If time difference is > jumpThreshold, seek instead of wait to sync
+ const jumpThreshold = React.useRef(1);
+ // If media isn't playing after stallThreshold milliseconds,
+ // consider the playback to be stalled (e.g. from buffering)
+ const stallThreshold = React.useRef(1000);
+
+ // Media event handlers
+ const handlePlayState = (event: "play" | "pause", index: number) => {
+ // Ignore event if no state change
+ // e.g. isPlaying is true, and event is play
+ if (isPlaying.current === (event === "play")) return;
+
+ // Ignore event if media is syncing
+ if (syncStates.current[index]) return;
+
+ // Ignore event if visibilityState is hidden
+ // and event comes from a video element. This is
+ // because browsers like Chrome will pause a video
+ // when the current tab is not visible.
+ lastVisibilityState.current = document.visibilityState;
+ if (
+ // lastVisibilityState.current === "hidden" &&
+ childRefs[index].current instanceof HTMLVideoElement
+ )
+ return;
+
+ // Otherwise, update playback state
+ isPlaying.current = event === "play";
+ console.log("[MediaSync]", event, "from", index);
+
+ // Also notify parent
+ if (event === "play") props.onPlay?.();
+ else props.onPause?.();
+ };
+
+ // Supply functions for this component's refs
+ useImperativeHandle(ref, () => ({
+ play: () => {
+ isPlaying.current = true;
+ props.onPlay?.();
+ },
+ pause: () => {
+ isPlaying.current = false;
+ props.onPause?.();
+ },
+ seek: (timeSeconds: number) => {
+ childRefs.forEach(({ current }) => (current.currentTime = timeSeconds));
+ },
+ }));
+
+ useAnimationFrame(() => {
+ // Skip if refs aren't ready yet
+ if (childRefs.findIndex((ref) => !ref.current) > -1) return;
+
+ // Get timestamps for each media element
+ const timestamps = childRefs.map(
+ ({ current }) => current?.currentTime || 0
+ );
+
+ // Assume the earliest time is the actual time
+ // e.g. video buffers and audio is playing, take the video time
+ // Except when the last visibility state is 'hidden', in which case
+ // take the latest time (usually comes from the audio)
+ const actualTime =
+ lastVisibilityState.current === "visible"
+ ? Math.min(...timestamps)
+ : Math.max(...timestamps);
+
+ // Check if media is stalled
+ const now = Date.now();
+ if (actualTime !== lastTime.current) lastTimeUpdate.current = now;
+ lastTime.current = actualTime;
+ const isStalled =
+ now - lastTimeUpdate.current > stallThreshold.current &&
+ isPlaying.current;
+
+ // Update sync states
+ // true -> out of sync, need to be paused if ahead or seeked if behind
+ // false -> in sync
+ // Flip to true when media is ahead by syncThreshold
+ // Only flip back to false when the timestamp === actualTime
+ syncStates.current = timestamps.map(
+ (ts, i) =>
+ Math.abs(ts - actualTime) > syncedThreshold.current &&
+ (syncStates[i] || Math.abs(ts - actualTime) > syncThreshold.current)
+ );
+
+ // Update media playback state
+ childRefs.forEach(({ current }, index) => {
+ if (isPlaying.current && syncStates.current[index]) {
+ // Media is ahead, pause if not yet paused
+ if (current.currentTime > actualTime && !current.paused)
+ current.pause();
+
+ // Seek media if time difference is greater than the jump threshold,
+ // or if the media is behind actualTime
+ if (
+ current.currentTime - actualTime > jumpThreshold.current ||
+ current.currentTime < actualTime
+ ) {
+ current.currentTime = actualTime;
+ syncStates.current[index] = false;
+ }
+ } else {
+ // Media is in sync, update playback state to reflect isPlaying
+ if (isPlaying.current && current.paused) current.play();
+ else if (!isPlaying.current && !current.paused) current.pause();
+
+ // Update visibility state
+ lastVisibilityState.current = document.visibilityState;
+ }
+ });
+
+ // Update parent with new state
+ props.onStateUpdate?.({
+ duration: childRefs[0].current?.duration,
+ timeSeconds: actualTime,
+ isPlaying: isPlaying.current,
+ isSynced: !syncStates.current.includes(true),
+ isStalled,
+ });
+ }, [childRefs]);
+
+ return (
+ <>
+ {React.Children.map(
+ props.children,
+ (child: React.ReactElement, idx: number) => {
+ // If child element has an existing ref, link it
+ // with the one from this component.
+ // @ts-ignore
+ if (child.ref) {
+ // @ts-ignore
+ child.ref.current = childRefs[idx].current;
+ }
+ return React.cloneElement(child, {
+ ref: childRefs[idx],
+ onPlay: () => handlePlayState("play", idx),
+ onPause: () => handlePlayState("pause", idx),
+ });
+ }
+ )}
+ </>
+ );
+});
+
+export default MediaSync;
diff --git a/modules/VideoPlayer/SeekBar.tsx b/modules/VideoPlayer/SeekBar.tsx
new file mode 100644
index 0000000..4109994
--- /dev/null
+++ b/modules/VideoPlayer/SeekBar.tsx
@@ -0,0 +1,88 @@
+import React from "react";
+import { formatSeconds } from "../format";
+
+export type SeekBarProps = {
+ value: number;
+ max: number;
+ buffer?: number;
+ onChange: (value: number) => void;
+ onMouseMove?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
+
+ videoId?: string;
+};
+
+const SeekBar = (props: SeekBarProps) => {
+ const { value, max, onChange } = props;
+ const buffer = props.buffer || 0;
+ const [isScrubbing, setIsScrubbing] = React.useState(false);
+ const [hoverPercentX, setHoverPercentX] = React.useState(0);
+ const [isMouseOver, setIsMouseOver] = React.useState(false);
+
+ const handleMouseMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const percent = Math.min(1, Math.max(0, (e.clientX - rect.x) / rect.width));
+ setHoverPercentX(percent);
+ if (isScrubbing) onChange(percent * max);
+
+ props.onMouseMove?.(e);
+ };
+
+ return (
+ <>
+ <div
+ className="w-full h-4 relative group cursor-pointer"
+ onMouseDown={() => {
+ setIsScrubbing(true);
+ onChange(hoverPercentX * max);
+ }}
+ onMouseUp={() => setIsScrubbing(false)}
+ onMouseMove={handleMouseMove}
+ onMouseEnter={() => setIsMouseOver(true)}
+ onMouseLeave={() => setIsMouseOver(false)}
+ aria-label="Seekbar"
+ aria-valuenow={value}
+ >
+ <div className="absolute bottom-1 w-full pt-4 pb-2 -mb-2">
+ <div className="relative w-full h-0.5 group-hover:h-1 transition-all duration-200">
+ <div className="absolute bg-white opacity-50 h-full w-full" />
+ <div
+ className="absolute bg-white opacity-50 h-full"
+ style={{
+ width: 100 * (buffer / max) + "%",
+ }}
+ />
+ <div
+ className="absolute bg-white opacity-50 h-full"
+ style={{
+ width: 100 * (isMouseOver ? hoverPercentX : 0) + "%",
+ }}
+ />
+ <div
+ className="absolute bg-blue-500 h-full"
+ style={{
+ width: (100 * value) / max + "%",
+ }}
+ />
+ </div>
+ </div>
+ <div
+ className={[
+ "absolute bottom-4 pointer-events-none",
+ "text-center rounded overflow-hidden",
+ ].join(" ")}
+ style={{
+ opacity: isMouseOver ? 1 : 0,
+ left: Math.min(90, Math.max(10, hoverPercentX * 100)) + "%",
+ transform: "translateX(-50%)",
+ }}
+ >
+ <div className="bg-black bg-opacity-75 px-2 py-1 text-center">
+ {formatSeconds(hoverPercentX * max)}
+ </div>
+ </div>
+ </div>
+ </>
+ );
+};
+
+export default SeekBar;
diff --git a/modules/VideoPlayer/VideoPlayer2.tsx b/modules/VideoPlayer/VideoPlayer2.tsx
new file mode 100644
index 0000000..bbc2c68
--- /dev/null
+++ b/modules/VideoPlayer/VideoPlayer2.tsx
@@ -0,0 +1,558 @@
+import React from "react";
+import { formatSeconds } from "../format";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import {
+ IconCamera,
+ IconClosedCaptioningRegular,
+ IconClosedCaptioningSolid,
+ IconCompress,
+ IconExpand,
+ IconPause,
+ IconPlay,
+ IconVolume,
+ IconVolumeMute,
+} from "../icons";
+import { checkAutoplay } from "../util";
+import LoaderRing from "./components/LoaderRing";
+import SeekBar from "./SeekBar";
+import { CaptionsRenderer } from "react-srv3";
+import MediaSync, { MediaSyncRef, MediaSyncState } from "./MediaSync";
+import { NextImage } from "../NextImage";
+
+type CaptionsTrack = {
+ lang: string;
+ src: string;
+};
+
+export type VideoPlayerProps = {
+ srcVideo: string;
+ srcAudio: string;
+ srcPoster?: string;
+ captions?: CaptionsTrack[];
+ onPlaybackProgress?: (progress: number) => any;
+ autoplay?: boolean;
+ showWatermark?: boolean;
+ videoId?: string;
+};
+
+const VideoPlayer2 = (props: VideoPlayerProps) => {
+ const { srcVideo, srcAudio, srcPoster, videoId, showWatermark } = props;
+ const captions = props.captions || [];
+ const hasCaptions = captions.length > 0;
+ const enCaptionIndex = captions.findIndex(
+ (cap) => cap.lang === "en" || cap.lang.startsWith("en-")
+ );
+
+ const refSelf = React.useRef<HTMLDivElement>(null);
+ const refAudio = React.useRef<HTMLAudioElement>(null);
+ const refVideo = React.useRef<HTMLVideoElement>(null);
+ const refMedia = React.useRef<MediaSyncRef>(null);
+
+ const lastMediaState = React.useRef<MediaSyncState>(null);
+
+ const [bufferProgress, setBufferProgress] = React.useState(0);
+ const [lastActive, setLastActive] = React.useState(Date.now());
+ const [isVideoErrored, setIsVideoErrored] = React.useState(false);
+ const [videoErrorMessage, setVideoErrorMessage] = React.useState("");
+ const [srv3CaptionXMLs, setSrv3CaptionXMLs] = React.useState([]);
+
+ const [audioVolume, setAudioVolume] = useLocalStorage("player:volume", 1);
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
+ const [activeCaption, setActiveCaption] = React.useState(enCaptionIndex);
+
+ const [isDebugVisible, setIsDebugVisible] = React.useState(false);
+ const [isContextVisible, setIsContextVisible] = React.useState(false);
+ const [contextX, setContextX] = React.useState(0);
+ const [contextY, setContextY] = React.useState(0);
+
+ const handleCaptionsButton = () =>
+ setActiveCaption((now) => ((now + 2) % (captions.length + 1)) - 1);
+
+ const handleMuteUnmute = () => setAudioVolume((now) => (now === 0 ? 0.5 : 0));
+
+ const pingActivity = () => {
+ setLastActive(new Date().getTime());
+ };
+
+ const updateBufferLength = () => {
+ if (!refVideo.current || !lastMediaState.current) return;
+
+ let maxVideo = 0;
+ for (let i = 0; i < refVideo.current.buffered.length; i++)
+ maxVideo =
+ refVideo.current.buffered.start(i) <= lastMediaState.current.timeSeconds
+ ? Math.max(maxVideo, refVideo.current.buffered.end(i))
+ : maxVideo;
+
+ setBufferProgress(maxVideo);
+ };
+
+ const [_redraw, _setRedraw] = React.useState(0);
+ const redraw = () => _setRedraw((x) => ++x);
+
+ const handleVideoTimeUpdate = () => {
+ updateBufferLength();
+ props.onPlaybackProgress?.(lastMediaState.current.timeSeconds);
+ redraw();
+ };
+
+ const handlePlayPause = () => {
+ if (!refVideo.current) return;
+ pingActivity();
+
+ if (lastMediaState.current.isPlaying) refMedia.current.pause();
+ else refMedia.current.play();
+ };
+
+ const handleMediaError = (
+ e:
+ | React.SyntheticEvent<HTMLVideoElement, Event>
+ | React.SyntheticEvent<HTMLAudioElement, Event>
+ ) => {
+ const error =
+ // @ts-ignore
+ e.nativeEvent.path?.[0]?.error ||
+ // @ts-ignore
+ e.nativeEvent.originalTarget?.error ||
+ new Error("Unknown error");
+
+ refMedia.current.pause();
+ setIsVideoErrored(true);
+ console.error("Error playing video: ", error.message);
+ console.error(e);
+
+ // Try to find out the error
+ fetch(srcVideo)
+ .then((res) => res.text())
+ .then((text) => {
+ if (text.startsWith("{")) {
+ const json = JSON.parse(text);
+ setVideoErrorMessage(
+ String(
+ json.message || json.error.message || json.error.code || text
+ )
+ );
+ } else setVideoErrorMessage(text);
+ })
+ .catch((e) => console.error(e));
+ };
+
+ const nudgeTime = (delta: number) => {
+ const newTime = Math.max(
+ 0,
+ Math.min(refVideo.current.currentTime + delta, refVideo.current.duration)
+ );
+ refVideo.current.currentTime = newTime;
+ return newTime;
+ };
+
+ /**
+ * Screenshot current video frame and download it
+ */
+ const captureFrame = () => {
+ if (!refVideo.current) return;
+
+ // Create canvas
+ const canvas = document.createElement("canvas");
+ canvas.width = refVideo.current.videoWidth;
+ canvas.height = refVideo.current.videoHeight;
+ const ctx = canvas.getContext("2d");
+
+ // Draw video frame to canvas
+ ctx.drawImage(refVideo.current, 0, 0, canvas.width, canvas.height);
+
+ // Generate URI and download
+ const uri = canvas.toDataURL("image/png");
+ const a = document.createElement("a");
+ a.href = uri;
+ a.download =
+ "Screenshot " +
+ videoId +
+ " " +
+ Math.floor(refVideo.current.currentTime * 1000) +
+ "ms.png";
+ a.click();
+
+ // Clean up
+ canvas.remove();
+ a.remove();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
+ let handled = true;
+
+ if (e.ctrlKey || e.altKey || e.shiftKey) return false;
+
+ if (e.key === " " || e.key === "k") {
+ handlePlayPause();
+ } else if (e.key === "ArrowRight") {
+ nudgeTime(5);
+ } else if (e.key === "ArrowLeft") {
+ nudgeTime(-5);
+ } else if (e.key === "l") {
+ nudgeTime(10);
+ } else if (e.key === "j") {
+ nudgeTime(-10);
+ } else if (e.key === "ArrowDown") {
+ setAudioVolume((now) => Math.max(now - 0.1, 0));
+ } else if (e.key === "ArrowUp") {
+ setAudioVolume((now) => Math.min(now + 0.1, 1));
+ } else if (e.key === "m") {
+ handleMuteUnmute();
+ } else if (e.key === "f") {
+ handleFullscreen();
+ } else if (e.key === "c") {
+ handleCaptionsButton();
+ } else handled = false;
+
+ if (handled) {
+ pingActivity();
+ e.preventDefault();
+ }
+ };
+
+ const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
+ e.preventDefault();
+ const playerRect = e.currentTarget.getBoundingClientRect();
+ setContextX(e.clientX - playerRect.left);
+ setContextY(e.clientY - playerRect.top);
+ setIsContextVisible((now) => !now);
+ };
+
+ const handleFullscreen = () => {
+ if (!document.fullscreenElement) {
+ refSelf.current?.requestFullscreen();
+ setIsFullscreen(true);
+ } else {
+ document.exitFullscreen();
+ setIsFullscreen(false);
+ }
+ };
+
+ const handleSeek = (val: number) => {
+ pingActivity();
+ if (refMedia.current) refMedia.current.seek(val);
+ };
+
+ React.useEffect(() => {
+ if (!refVideo.current) return;
+ refAudio.current.volume = audioVolume;
+ }, [audioVolume]);
+
+ React.useEffect(() => {
+ if (props.autoplay) {
+ checkAutoplay().then((can) => {
+ if (can) refMedia.current?.play?.();
+ });
+ }
+ }, []);
+
+ const loadCaptions = async (url: string) => {
+ await fetch(url)
+ .then((res) => res.text())
+ .then((text) =>
+ setSrv3CaptionXMLs((now) => ({ ...now, [activeCaption]: text }))
+ );
+ };
+
+ React.useEffect(() => {
+ if (activeCaption >= 0) loadCaptions(captions[activeCaption].src);
+ }, [activeCaption]);
+
+ const controlsVisible = Date.now() - lastActive < 5000;
+
+ return (
+ <div
+ className={[
+ "video-player bg-black",
+ "focus:outline-none",
+ "w-full h-full",
+ isFullscreen ? "absolute inset-0 flex flex-col justify-center" : "",
+ ].join(" ")}
+ style={{
+ cursor: controlsVisible ? "auto" : "none",
+ }}
+ ref={refSelf}
+ onMouseLeave={() => setLastActive(0)}
+ onMouseMove={pingActivity}
+ onKeyDown={handleKeyDown}
+ onContextMenu={handleContextMenu}
+ onBlur={() => setIsContextVisible(false)}
+ tabIndex={0}
+ >
+ {isDebugVisible && (
+ <div
+ data-debug-window
+ className="bg-black bg-opacity-50 text-white absolute left-2 top-2 z-40 p-4 whitespace-pre font-mono text-sm"
+ >
+ <div className="float-right">
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setIsDebugVisible(false);
+ }}
+ >
+ [x]
+ </a>
+ </div>
+ {JSON.stringify(
+ { ...(lastMediaState.current || {}), activeCaption },
+ null,
+ 2
+ )}
+ </div>
+ )}
+ {isContextVisible && (
+ <>
+ <div
+ className="fixed inset-0 w-full h-full z-40"
+ onClick={() => setIsContextVisible(false)}
+ />
+ <div
+ data-context-menu
+ className="bg-black bg-opacity-50 rounded-lg absolute z-50 overflow-hidden"
+ style={{
+ left: contextX + "px",
+ top: contextY + "px",
+ }}
+ >
+ <div
+ className="cursor-pointer px-6 py-4 hover:bg-black bg-opacity-25"
+ onClick={() => {
+ setIsDebugVisible(true);
+ setIsContextVisible(false);
+ }}
+ >
+ Show debug info
+ </div>
+ </div>
+ </>
+ )}
+ <div className="w-full h-full relative overflow-hidden">
+ {srcPoster && (
+ <NextImage
+ className={
+ bufferProgress > 0 && lastMediaState.current?.timeSeconds > 0
+ ? "opacity-0"
+ : "z-10"
+ }
+ onClick={handlePlayPause}
+ aria-hidden
+ src={srcPoster}
+ layout="fill"
+ />
+ )}
+ <div
+ className={[
+ "absolute inset-0 pointer-events-none z-20 flex flex-col justify-center bg-black",
+ "transition duration-200",
+ isVideoErrored ? "bg-opacity-75" : "bg-opacity-25",
+ lastMediaState.current?.isStalled || isVideoErrored
+ ? "opacity-100"
+ : "opacity-0",
+ ].join(" ")}
+ >
+ {isVideoErrored ? (
+ <div className="text-center">
+ <p>Error playing video</p>
+ <p>{videoErrorMessage}</p>
+ </div>
+ ) : (
+ <LoaderRing />
+ )}
+ </div>
+ <div
+ className="absolute inset-x-0 bottom-0 z-30 px-6 pt-2 transition duration-200"
+ style={{
+ background:
+ "linear-gradient(0deg, rgba(0,0,0,0.7) 0%, transparent 100%)",
+ opacity: controlsVisible ? 1 : 0,
+ }}
+ >
+ {showWatermark && (
+ <div className="text-sm">
+ <a
+ target="_blank"
+ href={"https://archive.ragtag.moe/watch?v=" + videoId}
+ >
+ Hosted on <span className="font-bold">Ragtag Archive</span>
+ </a>
+ </div>
+ )}
+ <SeekBar
+ value={lastMediaState.current?.timeSeconds || 0}
+ max={lastMediaState.current?.duration || 0}
+ buffer={bufferProgress}
+ onChange={handleSeek}
+ onMouseMove={pingActivity}
+ videoId={videoId}
+ />
+ <div className="flex justify-between">
+ <div className="flex items-center">
+ <button
+ type="button"
+ onMouseUp={(e) => {
+ e.preventDefault();
+ handlePlayPause();
+ }}
+ onTouchEnd={(e) => {
+ e.preventDefault();
+ handlePlayPause();
+ }}
+ className="py-3 px-4 focus:outline-none focus:bg-white focus:bg-opacity-25 rounded transition duration-200"
+ aria-label="Play/Pause button"
+ >
+ {lastMediaState.current?.isPlaying ? (
+ <IconPlay width="1em" height="1em" />
+ ) : (
+ <IconPause width="1em" height="1em" />
+ )}
+ </button>
+
+ <div className="hidden md:flex group">
+ <button
+ type="button"
+ onClick={handleMuteUnmute}
+ className="py-3 px-4 focus:outline-none focus:bg-white focus:bg-opacity-25 rounded transition duration-200"
+ aria-label="Mute/Unmute button"
+ >
+ {audioVolume === 0 ? (
+ <IconVolumeMute width="1em" height="1em" />
+ ) : (
+ <IconVolume width="1em" height="1em" />
+ )}
+ </button>
+
+ <div
+ className="
+ flex
+ h-12 w-0 group-hover:w-16
+ overflow-hidden
+ transition-all duration-200
+ "
+ >
+ <input
+ type="range"
+ className="slider w-16"
+ aria-label="Volume slider"
+ value={audioVolume}
+ min={0}
+ max={1}
+ step={0.01}
+ onChange={(e) => {
+ setAudioVolume(Number(e.target.value));
+ pingActivity();
+ }}
+ />
+ </div>
+ </div>
+
+ <p className="ml-4">
+ {formatSeconds(lastMediaState.current?.timeSeconds || 0)} /{" "}
+ {formatSeconds(lastMediaState.current?.duration || 0)}
+ </p>
+ </div>
+ <div>
+ {hasCaptions && (
+ <button
+ type="button"
+ aria-label="Toggle captions button"
+ onClick={handleCaptionsButton}
+ className="py-3 px-4 focus:outline-none focus:bg-white focus:bg-opacity-25 rounded transition duration-200"
+ >
+ {activeCaption === -1 ? (
+ <IconClosedCaptioningRegular
+ width="1em"
+ height="1em"
+ className="inline-block mr-2"
+ />
+ ) : (
+ <IconClosedCaptioningSolid
+ width="1em"
+ height="1em"
+ className="inline-block mr-2"
+ />
+ )}
+ <span className="leading-none">
+ {captions?.[activeCaption]?.lang || "off"}
+ </span>
+ </button>
+ )}
+ <button
+ type="button"
+ aria-label="Screenshot current video frame"
+ onClick={captureFrame}
+ className="hidden md:inline-block py-3 px-4 focus:outline-none focus:bg-white focus:bg-opacity-25 rounded transition duration-200"
+ >
+ <IconCamera width="1em" height="1em" />
+ </button>
+ <button
+ type="button"
+ aria-label="Toggle fullscreen button"
+ onClick={handleFullscreen}
+ className="py-3 px-4 focus:outline-none focus:bg-white focus:bg-opacity-25 rounded transition duration-200"
+ >
+ {isFullscreen ? (
+ <IconCompress width="1em" height="1em" />
+ ) : (
+ <IconExpand width="1em" height="1em" />
+ )}
+ </button>
+ </div>
+ </div>
+ </div>
+ {activeCaption > -1 && srv3CaptionXMLs[activeCaption] && (
+ <div
+ className={
+ "w-full h-full absolute z-10 pointer-events-none " +
+ (controlsVisible ? "controls-visible" : "")
+ }
+ >
+ <CaptionsRenderer
+ srv3={srv3CaptionXMLs[activeCaption]}
+ currentTime={lastMediaState.current?.timeSeconds || 0}
+ />
+ </div>
+ )}
+ <MediaSync
+ ref={refMedia}
+ onStateUpdate={(newState) => {
+ lastMediaState.current = newState;
+ if (activeCaption > -1) redraw();
+ }}
+ >
+ <video
+ ref={refVideo}
+ src={srcVideo}
+ className="w-full h-full absolute"
+ preload="auto"
+ crossOrigin="anonymous"
+ onMouseUp={(e) => {
+ e.preventDefault();
+ if (e.button === 0) handlePlayPause();
+ }}
+ onTouchEnd={(e) => {
+ e.preventDefault();
+ if (controlsVisible) handlePlayPause();
+ else pingActivity();
+ }}
+ onError={handleMediaError}
+ onTimeUpdate={handleVideoTimeUpdate}
+ playsInline
+ muted
+ />
+ <audio
+ preload="auto"
+ crossOrigin="anonymous"
+ ref={refAudio}
+ src={srcAudio}
+ onError={handleMediaError}
+ />
+ </MediaSync>
+ </div>
+ </div>
+ );
+};
+
+export default VideoPlayer2;
diff --git a/modules/VideoPlayer/components/LoaderRing.tsx b/modules/VideoPlayer/components/LoaderRing.tsx
new file mode 100644
index 0000000..6393166
--- /dev/null
+++ b/modules/VideoPlayer/components/LoaderRing.tsx
@@ -0,0 +1,10 @@
+const LoaderRing = (props: any) => (
+ <div className="lds-ring mx-auto" {...props}>
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+);
+
+export default LoaderRing;
diff --git a/modules/database.d.ts b/modules/database.d.ts
new file mode 100644
index 0000000..fb19a39
--- /dev/null
+++ b/modules/database.d.ts
@@ -0,0 +1,170 @@
+/**
+ * Video data
+ */
+
+export type VideoFile = {
+ name: string;
+ size: number;
+ url?: string;
+};
+
+export type VideoMetadata = {
+ video_id: string;
+ channel_name: string;
+ channel_id: string;
+ upload_date: string;
+ title: string;
+ description: string;
+ duration: number;
+ width: number;
+ height: number;
+ fps: number;
+ format_id: string;
+ view_count: number;
+ like_count: number;
+ dislike_count: number;
+ archived_timestamp: string;
+ files: VideoFile[];
+ drive_base: string;
+ timestamps?: {
+ actualStartTime?: string;
+ publishedAt?: string;
+ scheduledStartTime?: string;
+ actualEndTime?: string;
+ discoveredUnavailable?: string;
+ };
+};
+
+export type ElasticSearchDocument<T> = {
+ _index: string;
+ _type: string;
+ _id: string;
+ _score: string;
+ _source: T;
+};
+
+export type ElasticSearchResult<T> = {
+ took: number;
+ timed_out: boolean;
+ _shards: {
+ total: number;
+ successful: number;
+ skipped: number;
+ failed: number;
+ };
+ hits: {
+ total: {
+ value: number;
+ relation: "eq" | "gt" | "lt";
+ };
+ max_score: number;
+ hits: Array<ElasticSearchDocument<T>>;
+ };
+};
+
+/**
+ * Chat replay
+ */
+
+type ChatMessageEmoteImage = {
+ url: string;
+ id: string;
+ width?: number;
+ height?: number;
+};
+
+type ChatMessageEmote = {
+ id: string;
+ name: string;
+ shortcuts: string[];
+ search_terms: string[];
+ images: ChatMessageEmoteImage[];
+ is_custom_emoji: boolean;
+};
+
+type ChatMessageBase = {
+ time_in_seconds: number;
+ action_type: "add_chat_item" | "add_live_chat_ticker_item" | string;
+ message_id: string;
+ timestamp: number;
+ time_text: string;
+ message: string;
+ emotes?: ChatMessageEmote[];
+};
+
+type ChatMessageImage = {
+ url: string;
+ id: string;
+ width?: number;
+ height?: number;
+};
+
+type ChatMessageAuthorBadge = {
+ title: string;
+ icons: ChatMessageImage[];
+};
+
+type ChatMessageAuthor = {
+ name: string;
+ id: string;
+ images: ChatMessageImage[];
+ badges?: ChatMessageAuthorBadge[];
+};
+
+type ChatMessageMoney = {
+ text: string;
+ amount: number;
+ currency: string;
+ currency_symbol: string;
+};
+
+type ChatViewerEngagementMessage = ChatMessageBase & {
+ icon: "YOUTUBE_ROUND";
+ message_type: "viewer_engagement_message";
+};
+
+type ChatTextMessage = ChatMessageBase & {
+ message_type: "text_message";
+ author: ChatMessageAuthor;
+};
+
+type ChatMembershipItem = ChatMessageBase & {
+ message_type: "membership_item";
+ author: ChatMessageAuthor;
+};
+
+type ChatPaidMessage = ChatMessageBase & {
+ message_type: "paid_message";
+ author: ChatMessageAuthor & {
+ name_text_colour?: string;
+ };
+ money: ChatMessageMoney;
+ timestamp_colour: string;
+ body_background_colour: string;
+ header_text_colour: string;
+ header_background_colour: string;
+ body_text_colour: string;
+};
+
+export type ChatMessage =
+ | ChatViewerEngagementMessage
+ | ChatTextMessage
+ | ChatMembershipItem
+ | ChatPaidMessage;
+
+export const ChatMessageTypes = [
+ "paid_message",
+ "membership_item",
+ "text_message",
+ "viewer_engagement_message",
+] as const;
+export type ChatMessageType = typeof ChatMessageTypes[number];
+
+/**
+ * Search logs
+ */
+
+export type ElasticSearchLog = {
+ timestamp: string;
+ query: string;
+};
diff --git a/modules/format.ts b/modules/format.ts
new file mode 100644
index 0000000..47feee1
--- /dev/null
+++ b/modules/format.ts
@@ -0,0 +1,24 @@
+import prettyBytes from "pretty-bytes";
+
+export const formatSeconds = (seconds: number) => {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor((seconds % 3600) % 60);
+
+ const mmss =
+ m.toString().padStart(2, "0") + ":" + s.toString().padStart(2, "0");
+ if (h > 0) return h + ":" + mmss;
+ return mmss;
+};
+
+export const formatNumber = (num: number) =>
+ Intl.NumberFormat("en-US").format(num);
+
+export const formatDate = (date: Date) =>
+ Intl.DateTimeFormat("en", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(date);
+
+export const formatBytes = (bytes: number) => prettyBytes(bytes);
diff --git a/modules/hooks/useAnimationFrame.tsx b/modules/hooks/useAnimationFrame.tsx
new file mode 100644
index 0000000..ce7f8f6
--- /dev/null
+++ b/modules/hooks/useAnimationFrame.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+/**
+ * React hook to request animation frame
+ *
+ * Taken from https://css-tricks.com/using-requestanimationframe-with-react-hooks/
+ */
+export const useAnimationFrame = (
+ callback: (deltaTime: number) => void,
+ dependencies: any[] = []
+) => {
+ // Use useRef for mutable variables that we want to persist
+ // without triggering a re-render on their change
+ const requestRef = React.useRef(0);
+ const previousTimeRef = React.useRef(0);
+
+ const animate = (time: number) => {
+ if (previousTimeRef.current != undefined) {
+ const deltaTime = time - previousTimeRef.current;
+ callback(deltaTime);
+ }
+ previousTimeRef.current = time;
+ requestRef.current = requestAnimationFrame(animate);
+ };
+
+ React.useEffect(() => {
+ requestRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(requestRef.current);
+ }, dependencies);
+};
diff --git a/modules/hooks/useDebounce.tsx b/modules/hooks/useDebounce.tsx
new file mode 100644
index 0000000..6864217
--- /dev/null
+++ b/modules/hooks/useDebounce.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+
+export function useDebounce(value: any, delay: number) {
+ // State and setters for debounced value
+ const [debouncedValue, setDebouncedValue] = React.useState(value);
+
+ React.useEffect(
+ () => {
+ // Update debounced value after delay
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // Cancel the timeout if value changes (also on delay change or unmount)
+ // This is how we prevent debounced value from updating if value is changed ...
+ // .. within the delay period. Timeout gets cleared and restarted.
+ return () => {
+ clearTimeout(handler);
+ };
+ },
+ [value, delay] // Only re-call effect if value or delay changes
+ );
+
+ return debouncedValue;
+}
diff --git a/modules/hooks/useLocalStorage.tsx b/modules/hooks/useLocalStorage.tsx
new file mode 100644
index 0000000..60bd037
--- /dev/null
+++ b/modules/hooks/useLocalStorage.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+
+/**
+ * https://usehooks.com/useLocalStorage/
+ */
+export function useLocalStorage<T>(
+ key: string,
+ initialValue: T
+): [T, (value: T | ((val: T) => T)) => void] {
+ // State to store our value
+ // Pass initial state function to useState so logic is only executed once
+ const [storedValue, setStoredValue] = React.useState<T>(() => {
+ // try {
+ // // Get from local storage by key
+ // const item = window.localStorage.getItem(key);
+ // // Parse stored json or if none return initialValue
+ // return item ? JSON.parse(item) : initialValue;
+ // } catch (error) {
+ // If error also return initialValue
+ return initialValue;
+ // }
+ });
+
+ React.useEffect(() => {
+ // Get from local storage by key
+ const item = window.localStorage.getItem(key);
+ // Parse stored json or if none return initialValue
+ if (item) setStoredValue(JSON.parse(item));
+ }, []);
+
+ // Return a wrapped version of useState's setter function that ...
+ // ... persists the new value to localStorage.
+ const setValue = (value: T | ((val: T) => T)) => {
+ try {
+ // Allow value to be a function so we have same API as useState
+ const valueToStore =
+ value instanceof Function ? value(storedValue) : value;
+
+ // Save state
+ setStoredValue(valueToStore);
+
+ // Save to local storage
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
+ } catch (error) {}
+ };
+
+ return [storedValue, setValue];
+}
diff --git a/modules/hooks/useThrottle.tsx b/modules/hooks/useThrottle.tsx
new file mode 100644
index 0000000..77c9731
--- /dev/null
+++ b/modules/hooks/useThrottle.tsx
@@ -0,0 +1,24 @@
+/**
+ * https://github.com/bhaskarGyan/use-throttle/blob/master/src/index.js
+ */
+import { useState, useEffect, useRef } from "react";
+
+export const useThrottle = (value, limit) => {
+ const [throttledValue, setThrottledValue] = useState(value);
+ const lastRan = useRef(Date.now());
+
+ useEffect(() => {
+ const handler = setTimeout(function () {
+ if (Date.now() - lastRan.current >= limit) {
+ setThrottledValue(value);
+ lastRan.current = Date.now();
+ }
+ }, limit - (Date.now() - lastRan.current));
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, limit]);
+
+ return throttledValue;
+};
diff --git a/modules/hooks/useWindowSize.tsx b/modules/hooks/useWindowSize.tsx
new file mode 100644
index 0000000..0b72c9e
--- /dev/null
+++ b/modules/hooks/useWindowSize.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+
+export const useWindowSize = () => {
+ const [innerWidth, setInnerWidth] = React.useState(null);
+ const [innerHeight, setInnerHeight] = React.useState(null);
+
+ const handleResize = () => {
+ setInnerWidth(window.innerWidth);
+ setInnerHeight(window.innerHeight);
+ };
+
+ React.useEffect(() => {
+ handleResize();
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return { innerWidth, innerHeight };
+};
diff --git a/modules/icons.tsx b/modules/icons.tsx
new file mode 100644
index 0000000..7f6ff1d
--- /dev/null
+++ b/modules/icons.tsx
@@ -0,0 +1,219 @@
+export const IconBars = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M16 132h416c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H16C7.163 60 0 67.163 0 76v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"
+ />
+ </svg>
+);
+
+export const IconDownload = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z"
+ />
+ </svg>
+);
+
+export const IconYouTube = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"
+ />
+ </svg>
+);
+
+export const IconTV = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M592 0H48A48 48 0 0 0 0 48v320a48 48 0 0 0 48 48h240v32H112a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16H352v-32h240a48 48 0 0 0 48-48V48a48 48 0 0 0-48-48zm-16 352H64V64h512z"
+ />
+ </svg>
+);
+
+export const IconFileImport = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M16 288c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h112v-64zm489-183L407.1 7c-4.5-4.5-10.6-7-17-7H384v128h128v-6.1c0-6.3-2.5-12.4-7-16.9zm-153 31V0H152c-13.3 0-24 10.7-24 24v264h128v-65.2c0-14.3 17.3-21.4 27.4-11.3L379 308c6.6 6.7 6.6 17.4 0 24l-95.7 96.4c-10.1 10.1-27.4 3-27.4-11.3V352H128v136c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H376c-13.2 0-24-10.8-24-24z"
+ />
+ </svg>
+);
+
+export const IconInfoCircle = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"
+ />
+ </svg>
+);
+
+export const IconFileCode = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M384 121.941V128H256V0h6.059c6.365 0 12.47 2.529 16.971 7.029l97.941 97.941A24.005 24.005 0 0 1 384 121.941zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zM123.206 400.505a5.4 5.4 0 0 1-7.633.246l-64.866-60.812a5.4 5.4 0 0 1 0-7.879l64.866-60.812a5.4 5.4 0 0 1 7.633.246l19.579 20.885a5.4 5.4 0 0 1-.372 7.747L101.65 336l40.763 35.874a5.4 5.4 0 0 1 .372 7.747l-19.579 20.884zm51.295 50.479l-27.453-7.97a5.402 5.402 0 0 1-3.681-6.692l61.44-211.626a5.402 5.402 0 0 1 6.692-3.681l27.452 7.97a5.4 5.4 0 0 1 3.68 6.692l-61.44 211.626a5.397 5.397 0 0 1-6.69 3.681zm160.792-111.045l-64.866 60.812a5.4 5.4 0 0 1-7.633-.246l-19.58-20.885a5.4 5.4 0 0 1 .372-7.747L284.35 336l-40.763-35.874a5.4 5.4 0 0 1-.372-7.747l19.58-20.885a5.4 5.4 0 0 1 7.633-.246l64.866 60.812a5.4 5.4 0 0 1-.001 7.879z"
+ />
+ </svg>
+);
+
+export const IconPlay = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"
+ />
+ </svg>
+);
+
+export const IconPause = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"
+ />
+ </svg>
+);
+
+export const IconVolume = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.53 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z"
+ />
+ </svg>
+);
+
+export const IconVolumeMute = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM461.64 256l45.64-45.64c6.3-6.3 6.3-16.52 0-22.82l-22.82-22.82c-6.3-6.3-16.52-6.3-22.82 0L416 210.36l-45.64-45.64c-6.3-6.3-16.52-6.3-22.82 0l-22.82 22.82c-6.3 6.3-6.3 16.52 0 22.82L370.36 256l-45.63 45.63c-6.3 6.3-6.3 16.52 0 22.82l22.82 22.82c6.3 6.3 16.52 6.3 22.82 0L416 301.64l45.64 45.64c6.3 6.3 16.52 6.3 22.82 0l22.82-22.82c6.3-6.3 6.3-16.52 0-22.82L461.64 256z"
+ />
+ </svg>
+);
+
+export const IconClosedCaptioningSolid = (
+ props: React.SVGProps<SVGSVGElement>
+) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M464 64H48C21.5 64 0 85.5 0 112v288c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM218.1 287.7c2.8-2.5 7.1-2.1 9.2.9l19.5 27.7c1.7 2.4 1.5 5.6-.5 7.7-53.6 56.8-172.8 32.1-172.8-67.9 0-97.3 121.7-119.5 172.5-70.1 2.1 2 2.5 3.2 1 5.7l-17.5 30.5c-1.9 3.1-6.2 4-9.1 1.7-40.8-32-94.6-14.9-94.6 31.2.1 48 51.1 70.5 92.3 32.6zm190.4 0c2.8-2.5 7.1-2.1 9.2.9l19.5 27.7c1.7 2.4 1.5 5.6-.5 7.7-53.5 56.9-172.7 32.1-172.7-67.9 0-97.3 121.7-119.5 172.5-70.1 2.1 2 2.5 3.2 1 5.7L420 222.2c-1.9 3.1-6.2 4-9.1 1.7-40.8-32-94.6-14.9-94.6 31.2 0 48 51 70.5 92.2 32.6z"
+ />
+ </svg>
+);
+
+export const IconClosedCaptioningRegular = (
+ props: React.SVGProps<SVGSVGElement>
+) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M464 64H48C21.5 64 0 85.5 0 112v288c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zm-6 336H54c-3.3 0-6-2.7-6-6V118c0-3.3 2.7-6 6-6h404c3.3 0 6 2.7 6 6v276c0 3.3-2.7 6-6 6zm-211.1-85.7c1.7 2.4 1.5 5.6-.5 7.7-53.6 56.8-172.8 32.1-172.8-67.9 0-97.3 121.7-119.5 172.5-70.1 2.1 2 2.5 3.2 1 5.7l-17.5 30.5c-1.9 3.1-6.2 4-9.1 1.7-40.8-32-94.6-14.9-94.6 31.2 0 48 51 70.5 92.2 32.6 2.8-2.5 7.1-2.1 9.2.9l19.6 27.7zm190.4 0c1.7 2.4 1.5 5.6-.5 7.7-53.6 56.9-172.8 32.1-172.8-67.9 0-97.3 121.7-119.5 172.5-70.1 2.1 2 2.5 3.2 1 5.7L420 220.2c-1.9 3.1-6.2 4-9.1 1.7-40.8-32-94.6-14.9-94.6 31.2 0 48 51 70.5 92.2 32.6 2.8-2.5 7.1-2.1 9.2.9l19.6 27.7z"
+ />
+ </svg>
+);
+
+export const IconExpand = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"
+ />
+ </svg>
+);
+
+export const IconCompress = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"
+ />
+ </svg>
+);
+
+export const IconTachometerAlt = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm0 64c14.71 0 26.58 10.13 30.32 23.65-1.11 2.26-2.64 4.23-3.45 6.67l-9.22 27.67c-5.13 3.49-10.97 6.01-17.64 6.01-17.67 0-32-14.33-32-32S270.33 96 288 96zM96 384c-17.67 0-32-14.33-32-32s14.33-32 32-32 32 14.33 32 32-14.33 32-32 32zm48-160c-17.67 0-32-14.33-32-32s14.33-32 32-32 32 14.33 32 32-14.33 32-32 32zm246.77-72.41l-61.33 184C343.13 347.33 352 364.54 352 384c0 11.72-3.38 22.55-8.88 32H232.88c-5.5-9.45-8.88-20.28-8.88-32 0-33.94 26.5-61.43 59.9-63.59l61.34-184.01c4.17-12.56 17.73-19.45 30.36-15.17 12.57 4.19 19.35 17.79 15.17 30.36zm14.66 57.2l15.52-46.55c3.47-1.29 7.13-2.23 11.05-2.23 17.67 0 32 14.33 32 32s-14.33 32-32 32c-11.38-.01-20.89-6.28-26.57-15.22zM480 384c-17.67 0-32-14.33-32-32s14.33-32 32-32 32 14.33 32 32-14.33 32-32 32z"
+ />
+ </svg>
+);
+
+export const IconExclamationCirlce = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
+ />
+ </svg>
+);
+
+export const IconEllipsisV = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M96 184c39.8 0 72 32.2 72 72s-32.2 72-72 72-72-32.2-72-72 32.2-72 72-72zM24 80c0 39.8 32.2 72 72 72s72-32.2 72-72S135.8 8 96 8 24 40.2 24 80zm0 352c0 39.8 32.2 72 72 72s72-32.2 72-72-32.2-72-72-72-72 32.2-72 72z"
+ />
+ </svg>
+);
+
+export const IconPlayCircle = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm115.7 272l-176 101c-15.8 8.8-35.7-2.5-35.7-21V152c0-18.4 19.8-29.8 35.7-21l176 107c16.4 9.2 16.4 32.9 0 42z"
+ />
+ </svg>
+);
+
+export const IconCheck = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"
+ />
+ </svg>
+);
+
+export const IconChevronDown = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M207.029 381.476L12.686 187.132c-9.373-9.373-9.373-24.569 0-33.941l22.667-22.667c9.357-9.357 24.522-9.375 33.901-.04L224 284.505l154.745-154.021c9.379-9.335 24.544-9.317 33.901.04l22.667 22.667c9.373 9.373 9.373 24.569 0 33.941L240.971 381.476c-9.373 9.372-24.569 9.372-33.942 0z"
+ />
+ </svg>
+);
+
+export const IconChartBar = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M332.8 320h38.4c6.4 0 12.8-6.4 12.8-12.8V172.8c0-6.4-6.4-12.8-12.8-12.8h-38.4c-6.4 0-12.8 6.4-12.8 12.8v134.4c0 6.4 6.4 12.8 12.8 12.8zm96 0h38.4c6.4 0 12.8-6.4 12.8-12.8V76.8c0-6.4-6.4-12.8-12.8-12.8h-38.4c-6.4 0-12.8 6.4-12.8 12.8v230.4c0 6.4 6.4 12.8 12.8 12.8zm-288 0h38.4c6.4 0 12.8-6.4 12.8-12.8v-70.4c0-6.4-6.4-12.8-12.8-12.8h-38.4c-6.4 0-12.8 6.4-12.8 12.8v70.4c0 6.4 6.4 12.8 12.8 12.8zm96 0h38.4c6.4 0 12.8-6.4 12.8-12.8V108.8c0-6.4-6.4-12.8-12.8-12.8h-38.4c-6.4 0-12.8 6.4-12.8 12.8v198.4c0 6.4 6.4 12.8 12.8 12.8zM496 384H64V80c0-8.84-7.16-16-16-16H16C7.16 64 0 71.16 0 80v336c0 17.67 14.33 32 32 32h464c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"
+ />
+ </svg>
+);
+
+export const IconCamera = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M512 144v288c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V144c0-26.5 21.5-48 48-48h88l12.3-32.9c7-18.7 24.9-31.1 44.9-31.1h125.5c20 0 37.9 12.4 44.9 31.1L376 96h88c26.5 0 48 21.5 48 48zM376 288c0-66.2-53.8-120-120-120s-120 53.8-120 120 53.8 120 120 120 120-53.8 120-120zm-32 0c0 48.5-39.5 88-88 88s-88-39.5-88-88 39.5-88 88-88 88 39.5 88 88z"
+ />
+ </svg>
+);
+
+export const IconFilter = (props: React.SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path
+ fill="currentColor"
+ d="M487.976 0H24.028C2.71 0-8.047 25.866 7.058 40.971L192 225.941V432c0 7.831 3.821 15.17 10.237 19.662l80 55.98C298.02 518.69 320 507.493 320 487.98V225.941l184.947-184.97C520.021 25.896 509.338 0 487.976 0z"
+ />
+ </svg>
+);
diff --git a/modules/shared/DefaultHead.tsx b/modules/shared/DefaultHead.tsx
new file mode 100644
index 0000000..55744d9
--- /dev/null
+++ b/modules/shared/DefaultHead.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import Head from "next/head";
+
+const K_SITE_NAME = "Ragtag Archive";
+const K_SITE_DESCRIPTION = "Preserving culture, one stream at a time";
+
+type DefaultHeadProps = {
+ title?: string;
+};
+
+const DefaultHead = (props: DefaultHeadProps) => (
+ <Head>
+ <title>
+ {props.title ? props.title + " - " : ""}
+ {K_SITE_NAME}
+ </title>
+ <meta name="title" content={K_SITE_NAME} />
+ <meta name="description" content={K_SITE_DESCRIPTION} />
+ <meta property="og:type" content="website" />
+ <meta property="og:url" content="https://archive.ragtag.moe/" />
+ <meta property="og:title" content={K_SITE_NAME} />
+ <meta
+ property="og:image"
+ content="https://archive.ragtag.moe/favicon.png"
+ />
+ <meta property="og:description" content={K_SITE_DESCRIPTION} />
+ <meta property="twitter:card" content="summary" />
+ <meta property="twitter:url" content="https://archive.ragtag.moe/" />
+ <meta property="twitter:title" content={K_SITE_NAME} />
+ <meta
+ property="twitter:image"
+ content="https://archive.ragtag.moe/favicon.png"
+ />
+ <meta property="twitter:description" content={K_SITE_DESCRIPTION} />
+ <meta property="twitter:creator" content="@kitsune_cw" />
+ </Head>
+);
+
+export default DefaultHead;
diff --git a/modules/util.ts b/modules/util.ts
new file mode 100644
index 0000000..90fff88
--- /dev/null
+++ b/modules/util.ts
@@ -0,0 +1,25 @@
+export const checkAutoplay = (): Promise<boolean> =>
+ new Promise((resolve) => {
+ try {
+ const audio = new Audio();
+ audio.autoplay = true;
+ audio.addEventListener("play", () => resolve(true));
+ audio.addEventListener("error", () => resolve(false));
+ audio.src =
+ "data:audio/mpeg;base64,/+MYxAAAAANIAUAAAASEEB/jwOFM/0MM/90b/+RhST//w4NFwOjf///PZu////9lns5GFDv//l9GlUIEEIAAAgIg8Ir/JGq3/+MYxDsLIj5QMYcoAP0dv9HIjUcH//yYSg+CIbkGP//8w0bLVjUP///3Z0x5QCAv/yLjwtGKTEFNRTMuOTeqqqqqqqqqqqqq/+MYxEkNmdJkUYc4AKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
+ setTimeout(() => resolve(false), 5000);
+ } catch (e) {
+ resolve(false);
+ }
+ });
+
+export const proxyYT3 = (url: string): string => {
+ try {
+ const u = new URL(url);
+ if (u.hostname !== "yt3.ggpht.com") return url;
+ u.hostname = "archive-yt3-ggpht-proxy.ragtag.moe";
+ return u.toString();
+ } catch (ex) {
+ return "";
+ }
+};
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage