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/VideoPlayer/MediaSync.tsx | 194 +++++++++ modules/VideoPlayer/SeekBar.tsx | 88 ++++ modules/VideoPlayer/VideoPlayer2.tsx | 558 ++++++++++++++++++++++++++ modules/VideoPlayer/components/LoaderRing.tsx | 10 + 4 files changed, 850 insertions(+) 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 (limited to 'modules/VideoPlayer') 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; -- cgit v1.2.3