diff --git a/README.md b/README.md index 450adcd15..3d2ead0b7 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ If you want to import the player as a React component into your own code, or use parts of the player, you'll need to install the package as a dependency. You will also need to install a number of peer dependencies such as [luxon](https://github.com/moment/luxon), which we use for date and time purposes, -`react`/`react-dom`, `styled-components`. +`react`/`react-dom`. You can find an example of this under `example-player-react`, e.g.: ```js diff --git a/example-overlay-react/App.jsx b/example-overlay-react/App.jsx index b252de255..c660a4565 100644 --- a/example-overlay-react/App.jsx +++ b/example-overlay-react/App.jsx @@ -1,7 +1,5 @@ import React, { useState } from 'react' -import styled from 'styled-components' - import { Foundation, Liner, @@ -25,13 +23,6 @@ const MIDDLE_AREA = [ [0.5, -0.5], // bottom right coordinate ] -const Layers = styled.div` - position: relative; - width: 80vw; - height: 80vh; - border: 1px solid deepskyblue; -` - const App = () => { const [textPos1, setTextPos1] = useState([-1, 0.8]) const [textPos2, setTextPos2] = useState([-0.4, -0.5]) @@ -54,7 +45,12 @@ const App = () => {

To get started, edit src/App.tsx and save to reload.

- +
{ interact with the SVGs below me - +
) } diff --git a/example-overlay-react/Circle.jsx b/example-overlay-react/Circle.jsx index 32e1ac740..e37081dda 100644 --- a/example-overlay-react/Circle.jsx +++ b/example-overlay-react/Circle.jsx @@ -6,19 +6,12 @@ import React, { useState, } from 'react' -import styled from 'styled-components' - import { FoundationContext, LinerContext, useDraggable, } from 'media-stream-library/overlay' -const SvgCircle = styled.circle` - fill: rgb(0.5, 0.5, 0.5, 0.2); - stroke: grey; -` - /* * Circle * @@ -36,7 +29,7 @@ export const Circle = forwardRef( const [cx, cy] = toSvgBasis(pos) - return + return } ) @@ -99,7 +92,8 @@ export const DraggableCircle = forwardRef(({ pos, onChangePos, ...circleProps }, const [cx, cy] = svgPos return ( - { const { toSvgBasis, toUserBasis } = useContext(FoundationContext) const { clampCoord, clampCoordArray } = useContext(LinerContext) @@ -86,15 +66,16 @@ export const Polygon = ({ pos, onChangePos }) => { return ( - `${x},${y}`).join(' ')} /> + `${x},${y}`).join(' ')} /> {svgPos.map(([x, y], index) => { // The visible corners - return + return })} {svgPos.map(([x, y], index) => { // The invisible handles return ( - {children} - + ) } diff --git a/example-overlay-react/package.json b/example-overlay-react/package.json index 39b0c03e4..720a81397 100644 --- a/example-overlay-react/package.json +++ b/example-overlay-react/package.json @@ -2,16 +2,12 @@ "private": true, "dependencies": { "media-stream-library": "workspace:^", - "pepjs": "0.5.3", - "react": "18.3.1", - "react-dom": "18.3.1", - "styled-components": "5.3.11" + "pepjs": "^0.5.3", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { - "@types/react": "18.3.18", - "@types/react-dom": "18.3.5", - "@types/styled-components": "5.1.34", - "@vitejs/plugin-react": "4.3.4", - "vite": "6.0.7" + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" } } diff --git a/example-player-react/App.jsx b/example-player-react/App.jsx index e329047a0..a733916cf 100644 --- a/example-player-react/App.jsx +++ b/example-player-react/App.jsx @@ -1,38 +1,34 @@ import React, { useCallback, useState } from 'react' -import styled, { createGlobalStyle } from 'styled-components' - import { BasicStream } from './BasicStream' import { MultiStream } from './MultiStream' import { SingleStream } from './SingleStream' -const GlobalStyle = createGlobalStyle` - body { - margin: 0; - font-family: sans-serif; - } -` - -const AppContainer = styled.div` +const style = ` +body { + margin: 0; + font-family: sans-serif; +} +.appContainer { width: 100vw; height: 90vh; display: flex; flex-direction: column; align-items: center; -` - -const ButtonContainer = styled.div` +} +.buttonContainer { margin: 8px; text-align: center; + & button { + padding: 8px 12px; + margin: 4px; + background-color: lightpink; + &.selected { + background-color: lightgreen; + } + } +} ` - -const Button = styled.button` - padding: 8px 12px; - margin: 4px; - background-color: ${({ selected }) => - selected ? 'lightgreen' : 'lightpink'}; -` - const LOCALSTORAGE_KEY = 'media-stream-player-example' export const App = () => { @@ -55,23 +51,25 @@ export const App = () => { }, [setState]) return ( - - -

Media Stream Player

- - - - - - {state === 'single' ? : null} - {state === 'basic' ? : null} - {state === 'multi' ? : null} -
+ <> + +
+

Media Stream Player

+
+ + + +
+ {state === 'single' ? : null} + {state === 'basic' ? : null} + {state === 'multi' ? : null} +
+ ) } diff --git a/example-player-react/MultiStream.jsx b/example-player-react/MultiStream.jsx index e10770c13..a4860ec55 100644 --- a/example-player-react/MultiStream.jsx +++ b/example-player-react/MultiStream.jsx @@ -1,25 +1,7 @@ import React, { useEffect, useState } from 'react' -import styled from 'styled-components' - import { Player } from 'media-stream-library/player' -const MediaPlayer = styled(Player)` - max-width: 400px; - max-height: 300px; - margin: 8px; -` - -const MediaPlayerContainer = styled.div` - width: 400px; - height: 300px; - margin: 8px; -` - -const Centered = styled.div` - text-align: center; -` - // force auth const authorize = async (host) => { // Force a login by fetching usergroup @@ -73,21 +55,22 @@ export const MultiStream = () => { {state.length > 0 ? ( state.map((device) => { return device.authorized ? ( - - {device.hostname} - +
{device.hostname}
+ -
+ ) : ( - +
{device.hostname} Not authorized - +
) }) ) : ( diff --git a/example-player-react/package.json b/example-player-react/package.json index 9ccd78d41..41a6a9d81 100644 --- a/example-player-react/package.json +++ b/example-player-react/package.json @@ -1,12 +1,11 @@ { "private": true, "devDependencies": { - "@vitejs/plugin-react": "4.3.4", - "luxon": "3.5.0", + "@vitejs/plugin-react": "^4.3.4", + "luxon": "^3.5.0", "media-stream-library": "workspace:^", - "react": "18.3.1", - "react-dom": "18.3.1", - "styled-components": "5.3.11", - "vite": "6.0.7" + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^6.0.7" } } diff --git a/package.json b/package.json index d69523d23..b952d430d 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "luxon": "^3.0.0", "pepjs": ">= 0.5.3 < 1", "react": "^17.0.1 || ^18.0.0", - "react-dom": "^17.0.1 || ^18.0.0", - "styled-components": "^5.3.5" + "react-dom": "^17.0.1 || ^18.0.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -51,7 +50,6 @@ "@types/node": "22.10.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", - "@types/styled-components": "5.1.34", "@types/ws": "8.5.13", "@vitejs/plugin-react": "4.3.4", "c8": "10.1.3", @@ -67,7 +65,6 @@ "react-dom": "18.3.1", "react-is": "18.3.1", "semver": "7.6.3", - "styled-components": "5.3.11", "typescript": "5.7.3", "uvu": "0.5.6", "vite": "6.0.7", diff --git a/src/overlay/Foundation.tsx b/src/overlay/Foundation.tsx index 72b05be07..145c4a0e9 100644 --- a/src/overlay/Foundation.tsx +++ b/src/overlay/Foundation.tsx @@ -12,28 +12,12 @@ import React, { useState, } from 'react' -import styled, { css } from 'styled-components' - import { Matrix, apply, inverse, multiply } from './utils/affine' import { Coord } from './utils/geometry' type BaseElement = HTMLDivElement type BaseProps = HTMLAttributes -const Container = styled.div<{ readonly clickThrough: boolean }>` - ${({ clickThrough }) => - clickThrough - ? css` - pointer-events: none; - & > svg > * { - pointer-events: initial; - } - ` - : css` - pointer-events: initial; - `} -` - // Prototype of an Svg implementation with basis transform capabilities. /** @@ -212,7 +196,7 @@ export const Foundation = forwardRef< userBasis = DEFAULT_USER_BASIS, transformationMatrix, onReady, - className, + className = '', clickThrough = false, children, ...externalProps @@ -341,24 +325,28 @@ export const Foundation = forwardRef< [userBasis, toSvgBasis, toUserBasis] ) + const containerClassName = `${className} ${clickThrough ? 'clickthrough' : ''}` + /** * Render SVG drawing area. */ return ( - - - {contextValue !== undefined ? ( - - {children} - - ) : null} - - + <> + +
+ + {contextValue !== undefined ? ( + + {children} + + ) : null} + +
+ ) } ) diff --git a/src/player/BasicPlayer.tsx b/src/player/BasicPlayer.tsx index c4ea9d00f..c3127df09 100644 --- a/src/player/BasicPlayer.tsx +++ b/src/player/BasicPlayer.tsx @@ -9,22 +9,39 @@ import React, { } from 'react' import { Container, Layer } from './Container' -import { ControlArea, ControlBar } from './Controls' import { PlaybackArea, PlayerNativeElement, VapixParameters, VideoProperties, } from './PlaybackArea' -import { Button } from './components/Button' +import { Pause, Play } from './components' import { Limiter } from './components/Limiter' -import { MediaStreamPlayerContainer } from './components/MediaStreamPlayerContainer' import { useUserActive } from './hooks/useUserActive' -import { Pause, Play } from './img' import { Format } from './types' const DEFAULT_FORMAT = Format.JPEG +const controlAreaStyle = { + display: 'flex', + flexDirection: 'column', + fontFamily: 'sans', + height: '100%', + justifyContent: 'flex-end', + transition: 'opacity 0.3s ease-in-out', + width: '100%', +} as const + +const controlBarStyle = { + width: '100%', + height: '32px', + background: 'rgb(0, 0, 0, 0.66)', + display: 'flex', + alignItems: 'center', + padding: '0 16px', + boxSizing: 'border-box', +} as const + interface BasicPlayerProps { readonly hostname: string readonly vapixParams?: VapixParameters @@ -146,8 +163,13 @@ export const BasicPlayer = forwardRef( * container size. */ + const visible = play !== true || userActive + return ( - +
@@ -164,24 +186,22 @@ export const BasicPlayer = forwardRef( /> - - - - - +
+ {play === true ? ( + + ) : ( + + )} +
+
-
+ ) } ) diff --git a/src/player/Container.tsx b/src/player/Container.tsx index 52422600d..992ecfcdb 100644 --- a/src/player/Container.tsx +++ b/src/player/Container.tsx @@ -1,6 +1,4 @@ -import React from 'react' - -import styled from 'styled-components' +import React, { PropsWithChildren } from 'react' /** * Aspect ratio @@ -33,30 +31,38 @@ const getHeightPct = (aspectRatio: number) => { return 100 / aspectRatio } -const ContainerBody = styled.div.attrs<{ readonly aspectRatio: number }>( - ({ aspectRatio }) => { - return { style: { paddingTop: `${getHeightPct(aspectRatio)}%` } } - } -)<{ readonly aspectRatio: number }>` - width: 100%; - background: black; - position: relative; -` - -export const Layer = styled.div` - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; -` +export const Layer = ({ children }: PropsWithChildren) => { + return ( +
+ {children} +
+ ) +} interface ContainerProps { readonly aspectRatio?: number - readonly children: any // styled-components type mismatch } -export const Container: React.FC = ({ +export const Container = ({ aspectRatio = DEFAULT_ASPECT_RATIO, children, -}) => {children} +}: PropsWithChildren) => ( +
+ {children} +
+) diff --git a/src/player/Controls.tsx b/src/player/Controls.tsx index 8d81a1f98..dc8dcb119 100644 --- a/src/player/Controls.tsx +++ b/src/player/Controls.tsx @@ -1,126 +1,105 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { DateTime, Duration } from 'luxon' -import styled from 'styled-components' import { VapixParameters, VideoProperties } from './PlaybackArea' import { Settings } from './Settings' -import { Button } from './components/Button' +import { CogWheel, Pause, Play, Refresh, Screenshot, Stop } from './components' import { useUserActive } from './hooks/useUserActive' -import { CogWheel, Pause, Play, Refresh, Screenshot, Stop } from './img' import { Format } from './types' function isHTMLMediaElement(el: HTMLElement): el is HTMLMediaElement { return (el as HTMLMediaElement).buffered !== undefined } -export const ControlArea = styled.div<{ readonly visible: boolean }>` - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - opacity: ${({ visible }) => (visible ? 1 : 0)}; - transition: opacity 0.3s ease-in-out; -` - -export const ControlBar = styled.div` - width: 100%; - height: 32px; - background: rgb(0, 0, 0, 0.66); - display: flex; - align-items: center; - padding: 0 16px; - box-sizing: border-box; -` - -const VolumeContainer = styled.div` - margin-left: 8px; -` - -const Progress = styled.div` - flex-grow: 2; - padding: 0 32px; - display: flex; - align-items: center; -` - -const ProgressBarContainer = styled.div` - margin: 0; - width: 100%; - height: 24px; - position: relative; - display: flex; - flex-direction: column; - justify-content: center; -` - -const ProgressBar = styled.div` - background-color: rgba(255, 255, 255, 0.1); - height: 1px; - position: relative; - width: 100%; - - ${ProgressBarContainer}:hover > & { - height: 3px; - } -` +const controlAreaStyle = { + display: 'flex', + flexDirection: 'column', + fontFamily: 'sans', + height: '100%', + justifyContent: 'flex-end', + transition: 'opacity 0.3s ease-in-out', + width: '100%', +} as const + +const controlBarStyle = { + width: '100%', + height: '32px', + background: 'rgb(0, 0, 0, 0.66)', + display: 'flex', + alignItems: 'center', + padding: '0 16px', + boxSizing: 'border-box', +} as const + +const progressStyle = { + flexGrow: '2', + padding: '0 32px', + display: 'flex', + alignItems: 'center', +} as const + +const progressBarContainerStyle = { + margin: '0', + width: '100%', + height: '24px', + position: 'relative', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', +} as const + +const progressBarStyle = { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + height: '1px', + position: 'relative', + width: '100%', +} as const + +const progressBarPlayedStyle = (fraction = 0) => { + return { + transform: `scaleX(${fraction})`, + backgroundColor: 'rgb(240, 180, 0)', + height: '100%', + position: 'absolute', + top: '0', + transformOrigin: '0 0', + width: '100%', + } as const +} -const ProgressBarPlayed = styled.div.attrs<{ readonly fraction: number }>( - ({ fraction }) => { - return { - style: { transform: `scaleX(${fraction})` }, - } - } -)<{ readonly fraction: number }>` - background-color: rgb(240, 180, 0); - height: 100%; - position: absolute; - top: 0; - transform: scaleX(0); - transform-origin: 0 0; - width: 100%; -` - -const ProgressBarBuffered = styled.div.attrs<{ readonly fraction: number }>( - ({ fraction }) => { - return { - style: { transform: `scaleX(${fraction})` }, - } - } -)<{ readonly fraction: number }>` - background-color: rgba(255, 255, 255, 0.2); - height: 100%; - position: absolute; - top: 0; - transform: scaleX(0); - transform-origin: 0 0; - width: 100%; -` - -const ProgressTimestamp = styled.div.attrs<{ readonly left: number }>( - ({ left }) => { - return { - style: { left: `${left}px` }, - } - } -)<{ readonly left: number }>` - background-color: rgb(56, 55, 51); - border-radius: 3px; - bottom: 200%; - color: #fff; - font-size: 9px; - padding: 5px; - position: absolute; - text-align: center; -` - -const ProgressIndicator = styled.div` - color: rgb(240, 180, 0); - padding-left: 24px; - font-size: 10px; - white-space: nowrap; -` +const progressBarBufferedStyle = (fraction = 0) => { + return { + transform: `scaleX(${fraction})`, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + height: '100%', + position: 'absolute', + top: '0', + transformOrigin: '0 0', + width: '100%', + } as const +} + +const progressBarTimestampStyle = (left = 0) => { + return { + left: `${left}px`, + backgroundColor: 'rgb(56, 55, 51)', + borderRadius: '3px', + bottom: '200%', + color: '#fff', + fontSize: '9px', + padding: '5px', + position: 'absolute', + textAlign: 'center', + } as const +} + +const progressIndicatorStyle = { + color: 'rgb(240, 180, 0)', + paddingLeft: '24px', + fontSize: '10px', + whiteSpace: 'nowrap', +} as const interface ControlsProps { readonly play?: boolean @@ -344,36 +323,28 @@ export const Controls: React.FC = ({ } }, [startTime, totalDuration]) + const visible = play !== true || settings || userActive + return ( - - - - {src !== undefined && ( - +
+ {play === true ? ( + + ) : ( + )} + {src !== undefined && } {src !== undefined && ( - + )} {src !== undefined && ( - + )} {volume !== undefined ? ( - +
= ({ onChange={onVolumeChange} value={volume ?? 0} /> - +
) : null} - - - - - +
+
+
+
+
{timestamp.left !== 0 ? ( - +
{timestamp.label} - +
) : null} - - - +
+
+
{totalDuration === Infinity ? '∙ LIVE' : progress.counter} - - - - +
+
+ +
{settings && ( = ({ toggleStats={toggleStats} /> )} - +
) } diff --git a/src/player/Feedback.tsx b/src/player/Feedback.tsx index d8d01a57a..5cb24a3b8 100644 --- a/src/player/Feedback.tsx +++ b/src/player/Feedback.tsx @@ -1,22 +1,22 @@ import React from 'react' -import styled from 'styled-components' - -import { Spinner } from './img' - -const FeedbackArea = styled.div` - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -` +import { Spinner } from './components' interface FeedbackProps { readonly waiting?: boolean } export const Feedback: React.FC = ({ waiting = false }) => ( - {waiting && } +
+ {waiting && } +
) diff --git a/src/player/HttpMp4Video.tsx b/src/player/HttpMp4Video.tsx index 9f6b9b3a4..6e13852a3 100644 --- a/src/player/HttpMp4Video.tsx +++ b/src/player/HttpMp4Video.tsx @@ -1,22 +1,14 @@ import React, { useEffect, useRef, useState } from 'react' -import styled from 'styled-components' import { HttpMp4Pipeline, TransformationMatrix } from '../streams' import { VideoProperties } from './PlaybackArea' import { FORMAT_SUPPORTS_AUDIO } from './constants' import { useEventState } from './hooks/useEventState' -import { useVideoDebug } from './hooks/useVideoDebug' import { MetadataHandler } from './metadata' import { Format } from './types' import { logDebug } from './utils/log' -const VideoNative = styled.video` - max-height: 100%; - object-fit: contain; - width: 100%; -` - /** * WebSocket + RTSP playback component. */ @@ -91,8 +83,7 @@ export const HttpMp4Video: React.FC = ({ __onEndedRef.current = onEnded const __sensorTmRef = useRef() - - useVideoDebug(videoRef.current) + const __mimeRef = useRef('video/mp4') useEffect(() => { const videoEl = videoRef.current @@ -117,12 +108,13 @@ export const HttpMp4Video: React.FC = ({ if (__onPlayingRef.current !== undefined) { __onPlayingRef.current({ el: videoEl, - width: videoEl.videoWidth, + formatSupportsAudio: FORMAT_SUPPORTS_AUDIO[Format.MP4_H264], height: videoEl.videoHeight, + mime: __mimeRef.current, + pipeline: pipeline ?? undefined, sensorTm: __sensorTmRef.current, - formatSupportsAudio: FORMAT_SUPPORTS_AUDIO[Format.MP4_H264], - // TODO: no volume, need to expose tracks? - // TODO: no pipeline, can we even get stats? + volume: videoEl.volume, + width: videoEl.videoWidth, }) } } @@ -160,21 +152,34 @@ export const HttpMp4Video: React.FC = ({ const endedCallback = () => { __onEndedRef.current?.() } - pipeline.start().then(({ headers, finished }) => { - __sensorTmRef.current = parseTransformHeader( - headers.get('video-sensor-transform') ?? - headers.get('video-metadata-transform') - ) - finished.finally(() => { - endedCallback() + pipeline + .start() + .then(({ headers, finished }) => { + __mimeRef.current = headers.get('content-type') ?? 'video/mp4' + __sensorTmRef.current = parseTransformHeader( + headers.get('video-sensor-transform') ?? + headers.get('video-metadata-transform') + ) + finished.finally(() => { + endedCallback() + }) + }) + .catch((err) => { + console.error('failed to fetch video stream:', err) }) - }) logDebug('initiated data fetching') setFetching(true) } }, [play, pipeline, fetching]) - return + return ( +
) } diff --git a/src/player/Stats.tsx b/src/player/Stats.tsx index e337ae61c..7772faa3e 100644 --- a/src/player/Stats.tsx +++ b/src/player/Stats.tsx @@ -1,270 +1,128 @@ -import React, { - MouseEventHandler, - useCallback, - useEffect, - useState, -} from 'react' -import styled from 'styled-components' +import React, { useCallback, useEffect, useState } from 'react' -import { RtspMp4Pipeline } from '../streams' +import { HttpMp4Pipeline, RtspJpegPipeline, RtspMp4Pipeline } from '../streams' import { PlayerPipeline, VideoProperties } from './PlaybackArea' -import { useInterval } from './hooks/useInterval' -import { StreamStats } from './img' import { Format } from './types' -const isRtspMp4Pipeline = ( - pipeline: PlayerPipeline | undefined -): pipeline is RtspMp4Pipeline => { - return (pipeline as RtspMp4Pipeline)?.mp4.tracks !== undefined +function isRtspMp4Pipeline( + pipeline: PlayerPipeline +): pipeline is RtspMp4Pipeline { + return 'mp4' in pipeline } -const StatsWrapper = styled.div` - position: absolute; - top: 24px; - left: 24px; - width: 360px; - min-width: 240px; - max-width: 80%; - max-height: 80%; - border-radius: 4px; - background: #292929 0% 0% no-repeat padding-box; - opacity: 0.88; -` - -const StatsHeader = styled.div` - padding: 8px 24px; - border-bottom: 1px solid #525252; -` - -const StatsIcon = styled.span<{ readonly clickable: boolean }>` - width: 24px; - height: 24px; - float: left; - cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')}; - - & > svg { - fill: #e0e0e0; - } -` - -const StatsTitle = styled.span` - display: inline-block; - margin-left: 8px; - vertical-align: sub; - text-align: left; - font-size: 16px; - font-family: 'Open Sans', Sans-Serif; - line-height: 22px; - color: #f5f5f5; -` - -const StatsHide = styled.span` - float: right; - text-align: right; -` - -const HideLink = styled.a` - vertical-align: sub; - text-decoration: none; - font-size: 16px; - font-family: 'Open Sans', Sans-Serif; - line-height: 22px; - color: #b8b8b8; -` - -const Data = styled.div` - display: grid; - grid-template-columns: repeat(3, 1fr); - column-gap: 24px; - row-gap: 16px; - width: 100%; - padding: 16px 24px 24px; -` - -const StatItem = styled.div` - text-align: left; - font-family: 'Open Sans', Sans-Serif; -` - -const StatName = styled.div` - font-size: 12px; - line-height: 17px; - color: #b8b8b8; -` - -const StatValue = styled.div` - font-size: 13px; - line-height: 18px; - color: #e0e0e0; -` +function isRtspJpegPipeline( + pipeline: PlayerPipeline +): pipeline is RtspJpegPipeline { + return 'canvas' in pipeline +} -const StatsShow = styled.div` - position: absolute; - top: 24px; - left: 24px; - width: 32px; - height: 32px; - display: flex; - justify-content: center; - align-items: center; - border-radius: 4px; - background: #292929 0% 0% no-repeat padding-box; - opacity: 0.88; -` +function isHttpMp4Pipeline( + pipeline: PlayerPipeline +): pipeline is HttpMp4Pipeline { + return 'mediaElement' in pipeline +} interface StatsProps { - readonly format: string + readonly format: Format readonly videoProperties: VideoProperties readonly refresh: number readonly volume?: number - readonly expanded: boolean - readonly onToggleExpanded: (value: boolean) => void } -interface Stat { - readonly name: string - readonly value: string | number - readonly unit?: string +const streamLabel: Record = { + [Format.JPEG]: 'HTTP', + [Format.MJPEG]: 'HTTP', + [Format.RTP_H264]: 'WebSocket+RTSP', + [Format.RTP_JPEG]: 'WebSocket+RTSP', + [Format.MP4_H264]: 'HTTP', } -const StatsData: React.FC< - Omit -> = ({ format, videoProperties, refresh, volume }) => { - const [stats, setStats] = useState>([]) - - // Updates stat values - const updateValues = useCallback(() => { - let streamType = 'Unknown' - if (format === Format.JPEG) { - streamType = 'Still image' - } else if (format === Format.MJPEG) { - streamType = 'MJPEG' - } else if (format === Format.RTP_H264) { - streamType = 'RTSP (WebSocket)' - } else if (format === Format.RTP_JPEG) { - streamType = 'MJPEG' - } else if (format === Format.MP4_H264) { - streamType = 'MP4 (HTTP)' - } - const { width, height, pipeline } = videoProperties - let statsData: Array = [ - { - name: 'Stream type', - value: streamType, - }, - { - name: 'Resolution', - value: `${width}x${height}`, - }, - { - name: 'Refreshed', - value: refresh, - unit: refresh > 1 ? 'times' : 'time', - }, - ] - if (isRtspMp4Pipeline(pipeline)) { - pipeline.mp4.tracks.forEach(({ id, name, codec, bitrate, framerate }) => { - statsData = statsData.concat([ - { - name: `Track ${id}`, - value: `${name} (${codec})`, - }, - { - name: 'Frame rate', - value: framerate.toFixed(2), - unit: 'fps', - }, - { - name: 'Bitrate', - value: (bitrate / 1000).toFixed(1), - unit: 'kbit/s', - }, - ]) - }) - } - - if (volume !== undefined) { - statsData.push({ - name: 'Volume', - value: Math.floor(volume * 100), - unit: '%', - }) - } - - setStats(statsData) - }, [format, refresh, videoProperties, volume]) - - useEffect(() => { - updateValues() - }, [updateValues]) - - useInterval(updateValues, 1000) +const volumeLabel = (volume?: number) => { + if (volume === undefined) { + return '' + } + const volumeLevel = Math.floor(volume * 100) + return `🕪 ${volumeLevel}%` +} - return ( - - {stats.length > 0 - ? stats.map((stat) => { - return ( - - {stat.name} - - {`${stat.value} ${stat.unit !== undefined ? stat.unit : ''}`} - - - ) - }) - : null} - +const bufferLabel = (el: HTMLVideoElement) => { + if (el.buffered.length === 0) { + return 'buffer: -' + } + const buffer = Math.floor( + (el.buffered.end(el.buffered.length - 1) - el.currentTime) * 1000 ) + return `buffer: ${String(buffer).padStart(5)} ms` } -export const Stats: React.FC = ({ +export function Stats({ format, videoProperties, refresh, volume, - expanded, - onToggleExpanded, -}) => { - // Handles show/hide stats - const onToggleStats = useCallback>( - (e) => { - e.preventDefault() - onToggleExpanded(!expanded) +}: StatsProps) { + const [labels, setLabels] = useState() + const update = useCallback( + (pipeline: PlayerPipeline) => { + if (isRtspMp4Pipeline(pipeline)) { + setLabels([ + ...pipeline.mp4.tracks.map(({ name, codec, bitrate, framerate }) => { + return `${name} (${codec}) @ ${framerate.toFixed(2)} fps, ${(bitrate / 1000).toFixed(1).padStart(6)} kbit/s` + }), + bufferLabel(pipeline.videoEl), + ]) + } + if (isRtspJpegPipeline(pipeline)) { + const { framerate, bitrate } = pipeline + setLabels([ + `JPEG @ ${framerate.toFixed(2)} fps, ${(bitrate / 1000).toFixed(1).padStart(6)} kbit/s`, + ]) + } + if (isHttpMp4Pipeline(pipeline)) { + const { bitrate } = pipeline + setLabels([ + `${videoProperties.mime ?? 'video/mp4'} @ ${(bitrate / 1000).toFixed(1).padStart(6)} kbit/s (avg)`, + bufferLabel(pipeline.mediaElement), + ]) + } }, - [expanded, onToggleExpanded] + [videoProperties] ) + useEffect(() => { + const { pipeline } = videoProperties + if (pipeline) { + const refresh = setInterval(() => update(pipeline), 1000) + return () => { + clearInterval(refresh) + setLabels([]) + } + } + }, [update, videoProperties.pipeline]) + + const { width, height } = videoProperties + return ( - <> - {expanded ? ( - - - - - - Client stream data - - - Hide - - - - - - ) : ( - - - - - - )} - +
+
{`${streamLabel[format]} ${width}x${height} ${volumeLabel(volume)}, refreshed ${refresh}x`}
+ {labels?.map((label, i) => ( +
+          {label}
+        
+ ))} +
) } diff --git a/src/player/StillImage.tsx b/src/player/StillImage.tsx index 3a3b498d1..0c2e5ced5 100644 --- a/src/player/StillImage.tsx +++ b/src/player/StillImage.tsx @@ -1,19 +1,11 @@ import React, { useEffect, useRef } from 'react' -import styled from 'styled-components' - import { VideoProperties } from './PlaybackArea' import { FORMAT_SUPPORTS_AUDIO } from './constants' import { useEventState } from './hooks/useEventState' import { Format } from './types' import { logDebug } from './utils/log' -const ImageNative = styled.img` - max-height: 100%; - object-fit: contain; - width: 100%; -` - interface StillImageProps { readonly forwardedRef?: React.Ref readonly play?: boolean @@ -81,5 +73,10 @@ export const StillImage: React.FC = ({ }, [loaded]) logDebug('render image', loaded) - return + return ( + + ) } diff --git a/src/player/WsRtspCanvas.tsx b/src/player/WsRtspCanvas.tsx index 3ac4751a6..cd9de11cc 100644 --- a/src/player/WsRtspCanvas.tsx +++ b/src/player/WsRtspCanvas.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import styled from 'styled-components' import { Rtcp, RtspJpegPipeline, @@ -15,12 +14,6 @@ import { FORMAT_SUPPORTS_AUDIO } from './constants' import { Format } from './types' import { logDebug } from './utils/log' -const CanvasNative = styled.canvas` - max-height: 100%; - object-fit: contain; - width: 100%; -` - interface WsRtspCanvasProps { readonly forwardedRef?: React.Ref /** @@ -224,5 +217,10 @@ export const WsRtspCanvas: React.FC = ({ } }, [play, pipeline, fetching]) - return + return ( + + ) } diff --git a/src/player/WsRtspVideo.tsx b/src/player/WsRtspVideo.tsx index 59cdb0e79..381689882 100644 --- a/src/player/WsRtspVideo.tsx +++ b/src/player/WsRtspVideo.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import styled from 'styled-components' import { Rtcp, RtspMp4Pipeline, @@ -14,7 +13,6 @@ import { import { Range, VideoProperties } from './PlaybackArea' import { FORMAT_SUPPORTS_AUDIO } from './constants' import { useEventState } from './hooks/useEventState' -import { useVideoDebug } from './hooks/useVideoDebug' import { MetadataHandler, ScheduledMessage, @@ -23,12 +21,6 @@ import { import { Format } from './types' import { logDebug } from './utils/log' -const VideoNative = styled.video` - max-height: 100%; - object-fit: contain; - width: 100%; -` - /** * WebSocket + RTSP playback component. */ @@ -136,8 +128,6 @@ export const WsRtspVideo: React.FC = ({ const __sensorTmRef = useRef() - useVideoDebug(videoRef.current) - useEffect(() => { const videoEl = videoRef.current @@ -161,17 +151,21 @@ export const WsRtspVideo: React.FC = ({ if (__onPlayingRef.current !== undefined) { __onPlayingRef.current({ el: videoEl, - pipeline: pipeline ?? undefined, - width: videoEl.videoWidth, - height: videoEl.videoHeight, formatSupportsAudio: FORMAT_SUPPORTS_AUDIO[Format.RTP_H264], + height: videoEl.videoHeight, + media: pipeline?.mp4.tracks?.map(({ codec, name }) => ({ + codec, + name, + })), + pipeline: pipeline ?? undefined, + range: __rangeRef.current, + sensorTm: __sensorTmRef.current, volume: pipeline?.mp4.tracks?.find((track) => track.codec.startsWith('mp4a') ) ? videoEl.volume : undefined, - range: __rangeRef.current, - sensorTm: __sensorTmRef.current, + width: videoEl.videoWidth, }) } } @@ -268,5 +262,12 @@ export const WsRtspVideo: React.FC = ({ } }, [play, pipeline, fetching]) - return + return ( +