From e5f4b6020fafec14098b1c75ba31b5d0f53af378 Mon Sep 17 00:00:00 2001 From: Mal Date: Mon, 9 Jun 2025 02:10:29 +0200 Subject: [PATCH 01/17] UI optimizations - Altered handling of PeerConnection - Reduced use of useState where not needed - Starting working on custom Player UI overlay - Added feature to watch multiple streams at the same time - Use props instead of paths - Add modal component for various purposes --- web/package-lock.json | 10 + web/package.json | 2 + web/src/components/broadcast/Broadcast.tsx | 59 +++-- web/src/components/player/Player.tsx | 207 +++++++++++++----- web/src/components/player/PlayerPage.tsx | 78 +++++-- .../player/components/playPauseComponent.tsx | 46 ++++ .../components/qualitySelectorComponent.tsx | 42 ++++ .../player/components/volumeComponent.tsx | 25 +++ .../components/rootWrapper/rootWrapper.tsx | 2 +- web/src/components/selection/frontpage.tsx | 15 +- web/src/components/shared/modal.tsx | 67 ++++++ 11 files changed, 459 insertions(+), 94 deletions(-) create mode 100644 web/src/components/player/components/playPauseComponent.tsx create mode 100644 web/src/components/player/components/qualitySelectorComponent.tsx create mode 100644 web/src/components/player/components/volumeComponent.tsx create mode 100644 web/src/components/shared/modal.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 5f502848..a5d24b63 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "broadcast-box", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.2.0", "@web3-storage/parse-link-header": "^3.1.0", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -880,6 +881,15 @@ "node": ">=18" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", diff --git a/web/package.json b/web/package.json index 7f1459c7..7775a5f3 100644 --- a/web/package.json +++ b/web/package.json @@ -4,12 +4,14 @@ "private": true, "type": "module", "dependencies": { + "@heroicons/react": "^2.2.0", "@web3-storage/parse-link-header": "^3.1.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "scripts": { "start": "vite", + "host": "vite --host", "build": "vite build", "lint": "eslint ./src --max-warnings 0" }, diff --git a/web/src/components/broadcast/Broadcast.tsx b/web/src/components/broadcast/Broadcast.tsx index 27a27572..f91e6585 100644 --- a/web/src/components/broadcast/Broadcast.tsx +++ b/web/src/components/broadcast/Broadcast.tsx @@ -1,6 +1,7 @@ import React, {useEffect, useRef, useState} from 'react' import {useLocation} from 'react-router-dom' import ErrorHeader from '../error-header/errorHeader' +import {useNavigate} from 'react-router-dom' const mediaOptions = { audio: true, @@ -28,24 +29,36 @@ function getMediaErrorMessage(value: ErrorMessageEnum): string { function BrowserBroadcaster() { const videoRef = useRef(null) + + //TODO: Use prop instead of location const location = useLocation() + const navigate = useNavigate(); const [mediaAccessError, setMediaAccessError] = useState(null) const [publishSuccess, setPublishSuccess] = useState(false) - const [useDisplayMedia, setUseDisplayMedia] = useState(false) + const [useDisplayMedia, setUseDisplayMedia] = useState<"Screen" | "Webcam" | "None">("None"); + const [peerConnection, _] = useState(new RTCPeerConnection()); const [peerConnectionDisconnected, setPeerConnectionDisconnected] = useState(false) const apiPath = import.meta.env.VITE_API_PATH; + const endStream = () => { + navigate('/') + } + useEffect(() => { - const peerConnection = new RTCPeerConnection() + if (useDisplayMedia === "None" || !peerConnection) { + return; + } + let stream: MediaStream | undefined = undefined; if (!navigator.mediaDevices) { - setMediaAccessError(ErrorMessageEnum.NoMediaDevices); + setMediaAccessError(() => ErrorMessageEnum.NoMediaDevices); + setUseDisplayMedia(() => "None") return } - const mediaPromise = useDisplayMedia ? + const mediaPromise = useDisplayMedia == "Screen" ? navigator.mediaDevices.getDisplayMedia(mediaOptions) : navigator.mediaDevices.getUserMedia(mediaOptions) @@ -91,6 +104,7 @@ function BrowserBroadcaster() { peerConnection.oniceconnectionstatechange = () => { if (peerConnection.iceConnectionState === 'connected' || peerConnection.iceConnectionState === 'completed') { setPublishSuccess(true) + setMediaAccessError(() => null) setPeerConnectionDisconnected(false) } else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') { setPublishSuccess(false) @@ -102,7 +116,7 @@ function BrowserBroadcaster() { .createOffer() .then(offer => { peerConnection.setLocalDescription(offer) - .catch((err) => console.error(err)); + .catch((err) => console.error("SetLocalDescription", err)); fetch(`${apiPath}/whip`, { method: 'POST', @@ -117,10 +131,13 @@ function BrowserBroadcaster() { sdp: answer, type: 'answer' }) - .catch((err) => console.error(err)) + .catch((err) => console.error("SetRemoveDescription",err)) }) }) - }, setMediaAccessError) + }, (reason: ErrorMessageEnum) => { + setMediaAccessError(() => reason) + setUseDisplayMedia("None"); + }) return function cleanup() { peerConnection.close() @@ -147,12 +164,28 @@ function BrowserBroadcaster() { className='w-full h-full' /> - +
+ + +
+ + {publishSuccess && ( +
+ +
+ )} ) } diff --git a/web/src/components/player/Player.tsx b/web/src/components/player/Player.tsx index 4e38f309..20b51286 100644 --- a/web/src/components/player/Player.tsx +++ b/web/src/components/player/Player.tsx @@ -1,41 +1,84 @@ -import React, {ChangeEvent, createRef, Dispatch, SetStateAction, useEffect, useState} from 'react' +import React, { useEffect, useRef, useState} from 'react' import {parseLinkHeader} from '@web3-storage/parse-link-header' -import {useLocation} from 'react-router-dom' +import { ArrowsPointingOutIcon, Square2StackIcon } from "@heroicons/react/16/solid"; +import VolumeComponent from "./components/volumeComponent"; +import PlayPauseComponent from "./components/playPauseComponent"; +import QualitySelectorComponent from "./components/qualitySelectorComponent"; interface PlayerProps { + streamKey: string; cinemaMode: boolean; - peerConnectionDisconnected: boolean; - setPeerConnectionDisconnected: Dispatch>; + onCloseStream?: () => void; } const Player = (props: PlayerProps) => { + // TODO: + // - Implement shortcut buttons for Play/Pause, Mute/Unmute in player + const apiPath = import.meta.env.VITE_API_PATH; - const {cinemaMode, setPeerConnectionDisconnected} = props; + const {streamKey, cinemaMode} = props; - const location = useLocation(); - const videoRef = createRef(); + const videoRef = useRef(null); const [videoLayers, setVideoLayers] = useState([]); - const [mediaSrcObject, setMediaSrcObject] = useState(null); - const [layerEndpoint, setLayerEndpoint] = useState(''); + const [peerConnection, setPeerConnection] = useState(); + const [hasSignal, setHasSignal] = useState(false); + const layerEndpointRef = useRef(''); + const hasSignalRef = useRef(false); + const peerRef = useRef(peerConnection); + const badSignalCountRef = useRef(10); useEffect(() => { - if (videoRef.current) { - videoRef.current.srcObject = mediaSrcObject + hasSignalRef.current = hasSignal; + + const intervalHandler = () => { + peerRef.current?.getStats() + .then((stats) => { + stats.forEach(e => { + if (e.type === "candidate-pair") { + const signalIsValid = e.availableIncomingBitrate !== undefined; + badSignalCountRef.current = signalIsValid ? 0 : badSignalCountRef.current + 1; + + if (badSignalCountRef.current > 5) { + setHasSignal(() => false); + } else if (badSignalCountRef.current === 0 && !hasSignalRef.current) { + setHasSignal(() => true); + } + } + }) + }) + } + + const interval = setInterval(intervalHandler, hasSignal ? 15_000 : 2_500) + + return () => { + clearInterval(interval); } - }, [mediaSrcObject, videoRef]) + }, [hasSignal]); useEffect(() => { - const peerConnection = new RTCPeerConnection() + if (!peerConnection && !!videoRef.current) { + setPeerConnection(() => new RTCPeerConnection()); + } + }, [videoRef]) - peerConnection.ontrack = function (event: RTCTrackEvent) { - setMediaSrcObject(event.streams[0]) + useEffect(() => { + if (!peerConnection) { + return; } - peerConnection.oniceconnectionstatechange = () => { - if (peerConnection.iceConnectionState === 'connected' || peerConnection.iceConnectionState === 'completed') { - setPeerConnectionDisconnected(false) - } else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') { - setPeerConnectionDisconnected(true) + peerRef.current = peerConnection; + + peerConnection.addEventListener("track", (e) => { + if (e.transceiver.sender.track) { + e.transceiver.sender.track.onended = (track) => console.log("Track Ended", track) + } + }) + + peerConnection.ontrack = (event: RTCTrackEvent) => { + if (videoRef.current) { + videoRef.current.srcObject = event.streams[0]; + } else { + console.log("PeerConnection:OnTrack", "Could not assign to VideoRef", videoRef) } } @@ -54,7 +97,7 @@ const Player = (props: PlayerProps) => { method: 'POST', body: offer.sdp, headers: { - Authorization: `Bearer ${location.pathname.split('/').pop()}`, + Authorization: `Bearer ${streamKey}`, 'Content-Type': 'application/sdp' } }).then(r => { @@ -64,11 +107,11 @@ const Player = (props: PlayerProps) => { throw new DOMException("Missing link header"); } - setLayerEndpoint(`${window.location.protocol}//${parsedLinkHeader['urn:ietf:params:whep:ext:core:layer'].url}`) + layerEndpointRef.current = `${window.location.protocol}//${parsedLinkHeader['urn:ietf:params:whep:ext:core:layer'].url}` const evtSource = new EventSource(`${window.location.protocol}//${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`) evtSource.onerror = _ => evtSource.close(); - + evtSource.addEventListener("layers", event => { const parsed = JSON.parse(event.data) setVideoLayers(parsed['1']['layers'].map((layer: any) => layer.encodingId)) @@ -79,51 +122,105 @@ const Player = (props: PlayerProps) => { peerConnection.setRemoteDescription({ sdp: answer, type: 'answer' + }).catch((err) => console.error("RemoteDescription", err)) + }).catch((err) => { + console.error("PeerConnectionError", err) }) - .catch((err) => console.error(err)) - }) }) return function cleanup() { peerConnection.close() } - }, [location.pathname, setPeerConnectionDisconnected]) - - const onLayerChange = (event: ChangeEvent) => { - fetch(layerEndpoint, { - method: 'POST', - body: JSON.stringify({mediaId: '1', encodingId: event.target.value}), - headers: { - 'Content-Type': 'application/json' - } - }).catch((err) => console.error(err)) - } + }, [peerConnection]) + return ( - <> +
+
+ + {/*Opaque background*/} +
+ + {/*Buttons */} + {videoRef.current !== null && ( +
+
+ + + + { + videoRef.current!.muted = newState; + }}/> + +
+ + + videoRef.current?.requestPictureInPicture()}/> + videoRef.current?.requestFullscreen()}/> + +
+
)} + + {!!props.onCloseStream && ( + + )} + + {/* TODO: Find a way to discern No Stream available and No incoming bytes and show message accordingly*/} + {!hasSignal && ( +

+ {props.streamKey} is not currently streaming +

+ )} + +
+
) } diff --git a/web/src/components/player/PlayerPage.tsx b/web/src/components/player/PlayerPage.tsx index e20c9325..a55b0883 100644 --- a/web/src/components/player/PlayerPage.tsx +++ b/web/src/components/player/PlayerPage.tsx @@ -1,27 +1,75 @@ import React, {useContext, useState} from "react"; import {CinemaModeContext} from "./CinemaModeProvider"; import Player from "./Player"; -import ErrorHeader from "../error-header/errorHeader"; +import Modal from "../shared/modal"; +import {useNavigate} from "react-router-dom"; const PlayerPage = () => { + const navigate = useNavigate(); const {cinemaMode, toggleCinemaMode} = useContext(CinemaModeContext); - const [peerConnectionDisconnected, setPeerConnectionDisconnected] = useState(false) + const [streamKeys, setStreamKeys] = useState([window.location.pathname.substring(1)]); + const [isModalOpen, setIsModelOpen] = useState(false); + + const addStream = (streamKey: string) => { + if (streamKeys.some((key: string) => key.toLowerCase() === streamKey.toLowerCase())) { + return; + } + setStreamKeys((prev) => [...prev, streamKey]); + setIsModelOpen((prev) => !prev); + }; return ( -
- {peerConnectionDisconnected && WebRTC has disconnected or failed to connect at all 😭 } -
- - - + + {/*Show modal to add stream keys with*/} +
- ) -} +
+) +}; -export default PlayerPage +export default PlayerPage; \ No newline at end of file diff --git a/web/src/components/player/components/playPauseComponent.tsx b/web/src/components/player/components/playPauseComponent.tsx new file mode 100644 index 00000000..5930fb91 --- /dev/null +++ b/web/src/components/player/components/playPauseComponent.tsx @@ -0,0 +1,46 @@ +import React, {useEffect, useState} from "react"; +import {PauseIcon, PlayIcon} from "@heroicons/react/16/solid"; + +interface PlayPauseComponentProps { + videoRef: React.RefObject; +} + +const PlayPauseComponent = (props: PlayPauseComponentProps) => { + const [isPaused, setIsPaused] = useState(false); + + if (props.videoRef.current === null) { + return <>; + } + + useEffect(() => { + if (props.videoRef.current === null) { + return; + } + + const canPlayHandler = (e: Event) => props.videoRef.current?.play() + const playingHandler = (_: Event) => setIsPaused(() => false) + const pauseHandler = (_: Event) => setIsPaused(() => true); + + props.videoRef.current.addEventListener("canplay", canPlayHandler) + props.videoRef.current.addEventListener("playing", playingHandler) + props.videoRef.current.addEventListener("pause", pauseHandler) + + return () => { + if (props.videoRef.current) { + props.videoRef.current.removeEventListener("canplay", canPlayHandler); + props.videoRef.current.removeEventListener("playing", playingHandler); + props.videoRef.current.removeEventListener("pause", pauseHandler); + } + } + + }, []); + + if (isPaused) { + return props.videoRef.current?.play()}/> + } + if (!isPaused) { + return props.videoRef.current?.pause()}/> + } +} + +export default PlayPauseComponent \ No newline at end of file diff --git a/web/src/components/player/components/qualitySelectorComponent.tsx b/web/src/components/player/components/qualitySelectorComponent.tsx new file mode 100644 index 00000000..d892b05f --- /dev/null +++ b/web/src/components/player/components/qualitySelectorComponent.tsx @@ -0,0 +1,42 @@ +import React, {ChangeEvent, useState} from "react"; +import {ChartBarIcon} from "@heroicons/react/16/solid"; + +interface LayerProps { + encodingId: string; +} + +interface QualityComponentProps { + layers: LayerProps[]; + layerEndpoint: string; +} + +// TODO: +// - Create popup selector +const QualitySelectorComponent = (props: QualityComponentProps) => { + const [isOpen, setIsOpen] = useState(false); + + const onLayerChange = (event: ChangeEvent) => { + fetch(props.layerEndpoint, { + method: 'POST', + body: JSON.stringify({mediaId: '1', encodingId: event.target.value}), + headers: { + 'Content-Type': 'application/json' + } + }).catch((err) => console.error(err)) + } + + return ( + setIsOpen((prev) => !prev)}> + + + ) +} + +export default QualitySelectorComponent \ No newline at end of file diff --git a/web/src/components/player/components/volumeComponent.tsx b/web/src/components/player/components/volumeComponent.tsx new file mode 100644 index 00000000..8f1f05f0 --- /dev/null +++ b/web/src/components/player/components/volumeComponent.tsx @@ -0,0 +1,25 @@ +import React, {useEffect, useState} from "react"; +import {SpeakerWaveIcon, SpeakerXMarkIcon} from "@heroicons/react/16/solid"; + +interface VolumeComponentProps { + isMuted: boolean; + onStateChanged: (isMuted: boolean) => void; +} + +// TODO: +// Implement volume bar +const VolumeComponent = (props: VolumeComponentProps) => { + const [isMuted, setIsMuted] = useState(props.isMuted); + + useEffect(() => { + props.onStateChanged(isMuted); + }, [isMuted]); + + if (isMuted) { + return setIsMuted((prev) => !prev)}/> + } + if (!isMuted) { + return setIsMuted((prev) => !prev)}/> + } +} +export default VolumeComponent diff --git a/web/src/components/rootWrapper/rootWrapper.tsx b/web/src/components/rootWrapper/rootWrapper.tsx index a681e301..2e034878 100644 --- a/web/src/components/rootWrapper/rootWrapper.tsx +++ b/web/src/components/rootWrapper/rootWrapper.tsx @@ -10,7 +10,7 @@ const RootWrapper = () => { return (
{navbarEnabled && ( -