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/VideoPlayer | |
Add files via upload
Diffstat (limited to 'modules/VideoPlayer')
| -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 |
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; |
