import React, { useState, forwardRef, useEffect, useCallback, useMemo, useLayoutEffect, useRef, } from 'react' import { Sdp } from 'media-stream-library' import { Container, Layer } from './Container' import { PlaybackArea, VapixParameters, VideoProperties, PlayerNativeElement, } from './PlaybackArea' import { Controls } from './Controls' import { Feedback } from './Feedback' import { Stats } from './Stats' import { useSwitch } from './hooks/useSwitch' import { getImageURL } from './utils' import { MetadataHandler } from './metadata' import { Limiter } from './components/Limiter' import { MediaStreamPlayerContainer } from './components/MediaStreamPlayerContainer' import { Format } from './types' const DEFAULT_FORMAT = Format.JPEG interface PlayerProps { readonly hostname: string readonly vapixParams?: VapixParameters readonly initialFormat?: Format readonly autoPlay?: boolean readonly onSdp?: (msg: Sdp) => void readonly metadataHandler?: MetadataHandler /** * Set to true if the camera requires a secure * connection, "https" and "wss" protocols. */ readonly secure?: boolean readonly aspectRatio?: number readonly className?: string /** * When playing a recording, the time the video started * (used for labeling with an absolute time) formatted * as an ISO time, e.g.: 2021-02-03T12:21:57.465715Z */ readonly startTime?: string /** * When playing a recording, the total duration of the video * if known by the user (and not reported from backend) in * seconds. */ readonly duration?: number /** * Activate automatic retries on RTSP errors. */ readonly autoRetry?: boolean } export const Player = forwardRef( ( { hostname, vapixParams = {}, initialFormat = DEFAULT_FORMAT, autoPlay = false, onSdp, metadataHandler, secure, className, startTime, duration, autoRetry, }, ref, ) => { const [play, setPlay] = useState(autoPlay) const [offset, setOffset] = useState(0) const [refresh, setRefresh] = useState(0) const [host, setHost] = useState(hostname) const [waiting, setWaiting] = useState(autoPlay) const [volume, setVolume] = useState() const [format, setFormat] = useState(initialFormat) /** * VAPIX parameters */ const [parameters, setParameters] = useState(vapixParams) useEffect(() => { /** * Check if localStorage actually exists, since if you * server side render, localStorage won't be available. */ if (window?.localStorage !== undefined) { window.localStorage.setItem('vapix', JSON.stringify(parameters)) } }, [parameters]) /** * Stats overlay */ const [showStatsOverlay, toggleStatsOverlay] = useSwitch( window?.localStorage !== undefined ? window.localStorage.getItem('stats-overlay') === 'on' : false, ) useEffect(() => { if (window?.localStorage !== undefined) { window.localStorage.setItem( 'stats-overlay', showStatsOverlay ? 'on' : 'off', ) } }, [showStatsOverlay]) /** * Controls */ const [videoProperties, setVideoProperties] = useState() const onPlaying = useCallback( (props: VideoProperties) => { setVideoProperties(props) setWaiting(false) setVolume(props.volume) }, [setWaiting], ) const onPlayPause = useCallback(() => { if (play) { setPlay(false) } else { setWaiting(true) setHost(hostname) setPlay(true) } }, [play, hostname]) const onRefresh = useCallback(() => { setPlay(true) setRefresh((value) => value + 1) setWaiting(true) }, []) const onScreenshot = useCallback(() => { if (videoProperties === undefined) { return undefined } const { el, width, height } = videoProperties const imageURL = getImageURL(el, { width, height }) const link = document.createElement('a') const event = new window.MouseEvent('click') link.download = `snapshot_${Date.now()}.jpg` link.href = imageURL link.dispatchEvent(event) }, [videoProperties]) const onStop = useCallback(() => { setPlay(false) setHost('') setWaiting(false) }, []) const onVapix = useCallback((key: string, value: string) => { setParameters((p: typeof vapixParams) => { const newParams = { ...p, [key]: value } if (value === '') { delete newParams[key] } return newParams }) setRefresh((refreshCount) => refreshCount + 1) }, []) /** * Refresh when changing visibility (e.g. when you leave a tab the * video will halt, so when you return we need to play again). */ useEffect(() => { const cb = () => { if (document.visibilityState === 'visible') { setPlay(true) setHost(hostname) } else if (document.visibilityState === 'hidden') { setPlay(false) setWaiting(false) setHost('') } } document.addEventListener('visibilitychange', cb) return () => document.removeEventListener('visibilitychange', cb) }, [hostname]) /** * Aspect ratio * * This needs to be set so make the Container (and Layers) match the size of * the visible image of the video or still image. */ const naturalAspectRatio = useMemo(() => { if (videoProperties === undefined) { return undefined } const { width, height } = videoProperties return width / height }, [videoProperties]) /** * Limit video size. * * The video size should not expand outside the available container, and * should be recomputed on resize. */ const limiterRef = useRef(null) useLayoutEffect(() => { if (naturalAspectRatio === undefined || limiterRef.current === null) { return } const observer = new window.ResizeObserver(([entry]) => { const element = entry.target as HTMLElement const maxWidth = element.clientHeight * naturalAspectRatio element.style.maxWidth = `${maxWidth}px` }) observer.observe(limiterRef.current) return () => observer.disconnect() }, [naturalAspectRatio]) /** * Volume control on the VideoElement (h264 only) */ useEffect(() => { if (videoProperties?.volume !== undefined && volume !== undefined) { const videoEl = videoProperties.el as HTMLVideoElement videoEl.muted = volume === 0 videoEl.volume = volume } }, [videoProperties, volume]) /** * Render * * Each layer is positioned exactly on top of the visible image, since the * aspect ratio is carried over to the container, and the layers match the * container size. * * There is a layer for the spinner (feedback), a statistics overlay, and a * control bar with play/pause/stop/refresh and a settings menu. */ return ( {showStatsOverlay && videoProperties !== undefined ? ( ) : null} ) }, ) Player.displayName = 'Player'