diff options
| author | 0t4u <61939142+0t4u@users.noreply.github.com> | 2022-12-05 20:33:28 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-05 20:33:28 +0000 |
| commit | c6aa70a64731b608fade3acab48c1d1d2df280b6 (patch) | |
| tree | a92d130d6b927e520ab40692bc504db7606f2092 /modules | |
Add files via upload
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ChatReplay/ChatMessageRender.tsx | 171 | ||||
| -rw-r--r-- | modules/ChatReplay/ChatReplay.tsx | 74 | ||||
| -rw-r--r-- | modules/ChatReplay/ChatReplayPanel.tsx | 170 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/default.ts | 21 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/index.ts | 32 | ||||
| -rw-r--r-- | modules/ChatReplay/parser/yt-dlp.ts | 169 | ||||
| -rw-r--r-- | modules/NextImage.tsx | 7 | ||||
| -rw-r--r-- | modules/PageBase.tsx | 48 | ||||
| -rw-r--r-- | modules/VideoActionButtons.tsx | 144 | ||||
| -rw-r--r-- | modules/VideoPlayer/MediaSync.tsx | 194 | ||||
| -rw-r--r-- | modules/VideoPlayer/SeekBar.tsx | 88 | ||||
| -rw-r--r-- | modules/VideoPlayer/VideoPlayer2.tsx | 558 | ||||
| -rw-r--r-- | modules/VideoPlayer/components/LoaderRing.tsx | 10 | ||||
| -rw-r--r-- | modules/database.d.ts | 170 | ||||
| -rw-r--r-- | modules/format.ts | 24 | ||||
| -rw-r--r-- | modules/hooks/useAnimationFrame.tsx | 30 | ||||
| -rw-r--r-- | modules/hooks/useDebounce.tsx | 25 | ||||
| -rw-r--r-- | modules/hooks/useLocalStorage.tsx | 48 | ||||
| -rw-r--r-- | modules/hooks/useThrottle.tsx | 24 | ||||
| -rw-r--r-- | modules/hooks/useWindowSize.tsx | 19 | ||||
| -rw-r--r-- | modules/icons.tsx | 219 | ||||
| -rw-r--r-- | modules/shared/DefaultHead.tsx | 39 | ||||
| -rw-r--r-- | modules/util.ts | 25 |
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 ""; + } +}; |
