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/App.tsx b/web/src/App.tsx index fa75cb8c..5f282c2f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,10 @@ import React from 'react' import { Routes, Route } from 'react-router-dom' -import RootWrapper from './components/rootWrapper/rootWrapper' -import Frontpage from "./components/selection/frontpage"; import BrowserBroadcaster from "./components/broadcast/Broadcast"; import PlayerPage from "./components/player/PlayerPage"; +import RootWrapper from "./components/rootWrapper/RootWrapper"; +import Frontpage from "./components/selection/Frontpage"; function App() { return ( diff --git a/web/src/components/broadcast/Broadcast.tsx b/web/src/components/broadcast/Broadcast.tsx index 27a27572..0ec108b8 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 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/error-header/errorHeader.tsx b/web/src/components/error-header/ErrorHeader.tsx similarity index 100% rename from web/src/components/error-header/errorHeader.tsx rename to web/src/components/error-header/ErrorHeader.tsx diff --git a/web/src/components/player/Player.tsx b/web/src/components/player/Player.tsx index 4e38f309..cd58a31b 100644 --- a/web/src/components/player/Player.tsx +++ b/web/src/components/player/Player.tsx @@ -1,42 +1,73 @@ -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) => { const apiPath = import.meta.env.VITE_API_PATH; - const {cinemaMode, setPeerConnectionDisconnected} = props; - - const location = useLocation(); - const videoRef = createRef(); + const {streamKey, cinemaMode} = props; + + 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 > 2) { + setHasSignal(() => false); + } else if (badSignalCountRef.current === 0 && !hasSignalRef.current) { + setHasSignal(() => true); + } + } + }) + }) } - }, [mediaSrcObject, videoRef]) + + const interval = setInterval(intervalHandler, hasSignal ? 15_000 : 2_500) + + return () => { + clearInterval(interval); + } + }, [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.ontrack = (event: RTCTrackEvent) => { + if (videoRef.current) { + videoRef.current.srcObject = event.streams[0]; + } } peerConnection.addTransceiver('audio', {direction: 'recvonly'}) @@ -54,7 +85,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,14 +95,14 @@ 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)) + setVideoLayers(() => parsed['1']['layers'].map((layer: any) => layer.encodingId)) }) return r.text() @@ -79,51 +110,110 @@ const Player = (props: PlayerProps) => { peerConnection.setRemoteDescription({ sdp: answer, type: 'answer' - }) - .catch((err) => console.error(err)) + }).catch((err) => console.error("RemoteDescription", err)) + }).catch((err) => { + console.error("PeerConnectionError", 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*/} +
videoRef.current?.requestFullscreen()} + className="absolute w-full bg-gray-950 opacity-40 h-full"/> + + {/*Buttons */} + {videoRef.current !== null && ( +
+
+ + + + videoRef.current!.volume = newValue} + onStateChanged={(newState) => videoRef.current!.muted = newState} + /> + +
+ + + videoRef.current?.requestPictureInPicture()}/> + videoRef.current?.requestFullscreen()}/> + +
+
)} + + {!!props.onCloseStream && ( + + )} + + {videoLayers.length === 0 && !hasSignal && ( +

+ {props.streamKey} is not currently streaming +

+ )} + { + videoLayers.length > 0 && !hasSignal && ( +

+ Loading video +

) + } + +
+
) } diff --git a/web/src/components/player/PlayerPage.tsx b/web/src/components/player/PlayerPage.tsx index e20c9325..33bb1988 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 {useNavigate} from "react-router-dom"; +import {CinemaModeContext} from "../../providers/CinemaModeProvider"; +import ModalTextInput from "../shared/ModalTextInput"; 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 😭 } -
- - - +
+ {isModalOpen && ( + + title="Add stream" + message={"Insert stream key to add to multi stream"} + isOpen={isModalOpen} + canCloseOnBackgroundClick={false} + onClose={() => setIsModelOpen(false)} + onAccept={(result: string) => addStream(result)} + /> + )} + +
+
+ {streamKeys.map((streamKey) => + { + navigate('/') + } + : + () => { + setStreamKeys((prev) => prev.filter((key) => key !== streamKey)) + }} + /> + )} +
+ + {/*Implement footer menu*/} +
+ + + {/*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..f2f1cd27 --- /dev/null +++ b/web/src/components/player/components/PlayPauseComponent.tsx @@ -0,0 +1,54 @@ +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(true); + + if (props.videoRef.current === null) { + return <>; + } + + useEffect(() => { + if (props.videoRef.current === null) { + return; + } + + const canPlayHandler = (_: 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); + } + } + }, []); + + useEffect(() => { + if(isPaused){ + props.videoRef.current?.pause(); + } + if(!isPaused){ + props.videoRef.current?.play().catch((err) => console.error("VideoError", err)); + } + }, [isPaused]); + + 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..3556a27b --- /dev/null +++ b/web/src/components/player/components/QualitySelectorComponent.tsx @@ -0,0 +1,65 @@ +import React, {ChangeEvent, useState} from "react"; +import {ChartBarIcon} from "@heroicons/react/16/solid"; + +interface QualityComponentProps { + layers: string[]; + layerEndpoint: string; +} + +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)) + + setIsOpen(() => false) + } + + if(props.layers.length === 0){ + return <> + } + + return ( +
+ setIsOpen((prev) => !prev)}/> + + {isOpen && ( + + + )} +
+ ) +} + +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..f0383637 --- /dev/null +++ b/web/src/components/player/components/VolumeComponent.tsx @@ -0,0 +1,55 @@ +import React, {useEffect, useRef, useState} from "react"; +import {SpeakerWaveIcon, SpeakerXMarkIcon} from "@heroicons/react/16/solid"; + +interface VolumeComponentProps { + isMuted: boolean; + onStateChanged: (isMuted: boolean) => void; + onVolumeChanged: (value: number) => void; +} + +const VolumeComponent = (props: VolumeComponentProps) => { + const [isMuted, setIsMuted] = useState(props.isMuted); + const [showSlider, setShowSlider] = useState(false); + const volumeRef = useRef(20); + + useEffect(() => { + props.onStateChanged(isMuted); + }, [isMuted]); + + const onVolumeChange = (newValue: number) => { + if(isMuted && newValue !== 0){ + setIsMuted((_) => false) + } + if(!isMuted && newValue === 0){ + setIsMuted((_) => true) + } + + props.onVolumeChanged(newValue / 100); + } + + return
setShowSlider(true)} + onMouseLeave={() => setShowSlider(false)} + className="flex justify-start max-w-42 gap-2 items-center" + > + {isMuted && ( + setIsMuted((prev) => !prev)}/> + )} + {!isMuted && ( + setIsMuted((prev) => !prev)}/> + )} + onVolumeChange(parseInt(event.target.value))} + className={ + ` + ${!showSlider && ` + invisible + `} + w-18 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700`}/> +
+} +export default VolumeComponent diff --git a/web/src/components/rootWrapper/rootWrapper.tsx b/web/src/components/rootWrapper/RootWrapper.tsx similarity index 89% rename from web/src/components/rootWrapper/rootWrapper.tsx rename to web/src/components/rootWrapper/RootWrapper.tsx index a681e301..52c14af1 100644 --- a/web/src/components/rootWrapper/rootWrapper.tsx +++ b/web/src/components/rootWrapper/RootWrapper.tsx @@ -1,7 +1,7 @@ import { useContext } from 'react'; import { Link, Outlet } from 'react-router-dom' -import { CinemaModeContext } from "../player/CinemaModeProvider"; import React from 'react'; +import {CinemaModeContext} from "../../providers/CinemaModeProvider"; const RootWrapper = () => { const { cinemaMode } = useContext(CinemaModeContext); @@ -10,7 +10,7 @@ const RootWrapper = () => { return (
{navbarEnabled && ( -