aboutsummaryrefslogtreecommitdiffstats
path: root/modules/VideoPlayer
diff options
context:
space:
mode:
author0t4u <61939142+0t4u@users.noreply.github.com>2022-12-05 20:33:28 +0000
committerGitHub <noreply@github.com>2022-12-05 20:33:28 +0000
commitc6aa70a64731b608fade3acab48c1d1d2df280b6 (patch)
treea92d130d6b927e520ab40692bc504db7606f2092 /modules/VideoPlayer
Add files via upload
Diffstat (limited to 'modules/VideoPlayer')
-rw-r--r--modules/VideoPlayer/MediaSync.tsx194
-rw-r--r--modules/VideoPlayer/SeekBar.tsx88
-rw-r--r--modules/VideoPlayer/VideoPlayer2.tsx558
-rw-r--r--modules/VideoPlayer/components/LoaderRing.tsx10
4 files changed, 850 insertions, 0 deletions
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;
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage