From c6aa70a64731b608fade3acab48c1d1d2df280b6 Mon Sep 17 00:00:00 2001 From: 0t4u <61939142+0t4u@users.noreply.github.com> Date: Mon, 5 Dec 2022 20:33:28 +0000 Subject: Add files via upload --- modules/ChatReplay/ChatMessageRender.tsx | 171 ++++++++ modules/ChatReplay/ChatReplay.tsx | 74 ++++ modules/ChatReplay/ChatReplayPanel.tsx | 170 ++++++++ modules/ChatReplay/parser/default.ts | 21 + modules/ChatReplay/parser/index.ts | 32 ++ modules/ChatReplay/parser/yt-dlp.ts | 169 ++++++++ modules/NextImage.tsx | 7 + modules/PageBase.tsx | 48 +++ modules/VideoActionButtons.tsx | 144 +++++++ modules/VideoPlayer/MediaSync.tsx | 194 +++++++++ modules/VideoPlayer/SeekBar.tsx | 88 ++++ modules/VideoPlayer/VideoPlayer2.tsx | 558 ++++++++++++++++++++++++++ modules/VideoPlayer/components/LoaderRing.tsx | 10 + modules/database.d.ts | 170 ++++++++ modules/format.ts | 24 ++ modules/hooks/useAnimationFrame.tsx | 30 ++ modules/hooks/useDebounce.tsx | 25 ++ modules/hooks/useLocalStorage.tsx | 48 +++ modules/hooks/useThrottle.tsx | 24 ++ modules/hooks/useWindowSize.tsx | 19 + modules/icons.tsx | 219 ++++++++++ modules/shared/DefaultHead.tsx | 39 ++ modules/util.ts | 25 ++ 23 files changed, 2309 insertions(+) create mode 100644 modules/ChatReplay/ChatMessageRender.tsx create mode 100644 modules/ChatReplay/ChatReplay.tsx create mode 100644 modules/ChatReplay/ChatReplayPanel.tsx create mode 100644 modules/ChatReplay/parser/default.ts create mode 100644 modules/ChatReplay/parser/index.ts create mode 100644 modules/ChatReplay/parser/yt-dlp.ts create mode 100644 modules/NextImage.tsx create mode 100644 modules/PageBase.tsx create mode 100644 modules/VideoActionButtons.tsx create mode 100644 modules/VideoPlayer/MediaSync.tsx create mode 100644 modules/VideoPlayer/SeekBar.tsx create mode 100644 modules/VideoPlayer/VideoPlayer2.tsx create mode 100644 modules/VideoPlayer/components/LoaderRing.tsx create mode 100644 modules/database.d.ts create mode 100644 modules/format.ts create mode 100644 modules/hooks/useAnimationFrame.tsx create mode 100644 modules/hooks/useDebounce.tsx create mode 100644 modules/hooks/useLocalStorage.tsx create mode 100644 modules/hooks/useThrottle.tsx create mode 100644 modules/hooks/useWindowSize.tsx create mode 100644 modules/icons.tsx create mode 100644 modules/shared/DefaultHead.tsx create mode 100644 modules/util.ts (limited to 'modules') 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 ( + {token} + ); + }); + }; + + switch (msg.message_type) { + case "paid_message": + return ( +
+
+
+
+ {msg.author.name} + {msg.author.badges && msg.author.badges[0].icons ? ( + {msg.author.badges[0].title} + ) : null} +
+
{msg.money.text}
+
+
+ [{formatSeconds(msg.time_in_seconds)}] +
+
+
{generateMessageContent(msg)}
+
+ ); + case "membership_item": + return ( +
+
+
+
{msg.author.name}
+ {msg.author.badges && msg.author.badges[0].icons ? ( + {msg.author.badges[0].title} + ) : null} +
+
+ [{formatSeconds(msg.time_in_seconds)}] +
+
+ {generateMessageContent(msg)} +
+ ); + case "text_message": + const authorType = + msg.author.badges?.map(({ title }) => + title === "Owner" + ? "owner" + : title === "Moderator" + ? "moderator" + : title.toLowerCase().includes("member") + ? "member" + : "" + ) || []; + return ( +
+
+ + {msg.author.name} + {authorType.includes("moderator") && ( + + + + )} + {msg.author.badges?.map((badge) => + Array.isArray(badge.icons) ? ( + {badge.title} + ) : null + )} + + [{formatSeconds(msg.time_in_seconds)}] +
+ {generateMessageContent(msg)} +
+ ); + } + + 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( + 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([]); + + 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 ( +
+ {messages.map((msg) => ( + + ))} +
+ ); +}; + +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(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(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 ( +
{ + if (isErrored || downloadProgress < 0) downloadChatData(); + }} + > + {isErrored ? ( +

Error loading chat, click to retry

+ ) : downloadProgress < 0 ? ( + <> +

Chat replay available!

+

Click to Enable

+ + ) : downloadProgress === 0 ? ( +

Loading chat replay...

+ ) : ( +

Loaded {(downloadProgress / 1024 / 1024).toFixed(2)}MB

+ )} +
+ ); + + return ( +
+
+
+
Chat replay
+
+ + +
+
+ {isFilterVisible && ( +
+
+ setChatFilter(e.target.value)} + /> +
+
+ )} +
+ {isChatVisible && ( +
+
+ +
+
+ )} +
+ ); +}; + +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) => ({ + 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) => ({ + 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; + +export const NextImage = (props: NextImageProps) => ( + +); 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 ( +
+ + +
+ {props.children} +
+ {/*
+ Made with 🍝 by{' '} + + kitsune + + . +
+ */} +
+ ); +}; + +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 + ) => { + 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 && ( +
setIsMenuOpen(false)} + /> + )} +
+ + + Download ({videoHeight}p{video.fps}, {formatBytes(mkvSize)}) + + + + More download options + + {isMenuOpen && ( +
+ {fileURLs.map((file) => ( + + {file.label} + + ))} +
+ )} + + + Watch on YouTube + + {full && ( + <> +
+ {video.files.findIndex((file) => + file.name.endsWith(".chat.json") + ) > -1 && ( + + + + Chat explorer + + + )} + + )} +
+ + ); + } +); + +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((props, ref) => { + // Create refs for all media elements + const childCount = React.Children.count(props.children); + const childRefs = Array(childCount) + .fill(null) + .map(() => React.useRef(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) => 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) => { + 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 ( + <> +
{ + setIsScrubbing(true); + onChange(hoverPercentX * max); + }} + onMouseUp={() => setIsScrubbing(false)} + onMouseMove={handleMouseMove} + onMouseEnter={() => setIsMouseOver(true)} + onMouseLeave={() => setIsMouseOver(false)} + aria-label="Seekbar" + aria-valuenow={value} + > +
+
+
+
+
+
+
+
+
+
+ {formatSeconds(hoverPercentX * max)} +
+
+
+ + ); +}; + +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(null); + const refAudio = React.useRef(null); + const refVideo = React.useRef(null); + const refMedia = React.useRef(null); + + const lastMediaState = React.useRef(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 + | React.SyntheticEvent + ) => { + 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) => { + 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) => { + 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 ( +
setLastActive(0)} + onMouseMove={pingActivity} + onKeyDown={handleKeyDown} + onContextMenu={handleContextMenu} + onBlur={() => setIsContextVisible(false)} + tabIndex={0} + > + {isDebugVisible && ( +
+ + {JSON.stringify( + { ...(lastMediaState.current || {}), activeCaption }, + null, + 2 + )} +
+ )} + {isContextVisible && ( + <> +
setIsContextVisible(false)} + /> +
+
{ + setIsDebugVisible(true); + setIsContextVisible(false); + }} + > + Show debug info +
+
+ + )} +
+ {srcPoster && ( + 0 && lastMediaState.current?.timeSeconds > 0 + ? "opacity-0" + : "z-10" + } + onClick={handlePlayPause} + aria-hidden + src={srcPoster} + layout="fill" + /> + )} +
+ {isVideoErrored ? ( +
+

Error playing video

+

{videoErrorMessage}

+
+ ) : ( + + )} +
+
+ {showWatermark && ( + + )} + +
+
+ + +
+ + +
+ { + setAudioVolume(Number(e.target.value)); + pingActivity(); + }} + /> +
+
+ +

+ {formatSeconds(lastMediaState.current?.timeSeconds || 0)} /{" "} + {formatSeconds(lastMediaState.current?.duration || 0)} +

+
+
+ {hasCaptions && ( + + )} + + +
+
+
+ {activeCaption > -1 && srv3CaptionXMLs[activeCaption] && ( +
+ +
+ )} + { + lastMediaState.current = newState; + if (activeCaption > -1) redraw(); + }} + > + +
+
+ ); +}; + +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) => ( +
+
+
+
+
+
+); + +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 = { + _index: string; + _type: string; + _id: string; + _score: string; + _source: T; +}; + +export type ElasticSearchResult = { + 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>; + }; +}; + +/** + * 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( + 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(() => { + // 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) => ( + + + +); + +export const IconDownload = (props: React.SVGProps) => ( + + + +); + +export const IconYouTube = (props: React.SVGProps) => ( + + + +); + +export const IconTV = (props: React.SVGProps) => ( + + + +); + +export const IconFileImport = (props: React.SVGProps) => ( + + + +); + +export const IconInfoCircle = (props: React.SVGProps) => ( + + + +); + +export const IconFileCode = (props: React.SVGProps) => ( + + + +); + +export const IconPlay = (props: React.SVGProps) => ( + + + +); + +export const IconPause = (props: React.SVGProps) => ( + + + +); + +export const IconVolume = (props: React.SVGProps) => ( + + + +); + +export const IconVolumeMute = (props: React.SVGProps) => ( + + + +); + +export const IconClosedCaptioningSolid = ( + props: React.SVGProps +) => ( + + + +); + +export const IconClosedCaptioningRegular = ( + props: React.SVGProps +) => ( + + + +); + +export const IconExpand = (props: React.SVGProps) => ( + + + +); + +export const IconCompress = (props: React.SVGProps) => ( + + + +); + +export const IconTachometerAlt = (props: React.SVGProps) => ( + + + +); + +export const IconExclamationCirlce = (props: React.SVGProps) => ( + + + +); + +export const IconEllipsisV = (props: React.SVGProps) => ( + + + +); + +export const IconPlayCircle = (props: React.SVGProps) => ( + + + +); + +export const IconCheck = (props: React.SVGProps) => ( + + + +); + +export const IconChevronDown = (props: React.SVGProps) => ( + + + +); + +export const IconChartBar = (props: React.SVGProps) => ( + + + +); + +export const IconCamera = (props: React.SVGProps) => ( + + + +); + +export const IconFilter = (props: React.SVGProps) => ( + + + +); 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) => ( + + + {props.title ? props.title + " - " : ""} + {K_SITE_NAME} + + + + + + + + + + + + + + + +); + +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 => + 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 ""; + } +}; -- cgit v1.2.3