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