Merge pull request 'Fix stream switching' (#3) from bugfix/1-stream-switching into master

Reviewed-on: #3
This commit is contained in:
Torjus Håkestad 2023-12-04 22:37:37 +00:00
commit a267f9e0df
2 changed files with 151 additions and 113 deletions

View File

@ -23,9 +23,15 @@ body {
font-weight: 600; font-weight: 600;
} }
#main p { .loader {
margin-top: 10em; display: flex;
text-align: center; }
.loader .spinner {
margin-top: 0px;
}
.loader .loading-text {
margin-top: 0px;
margin-left: 0.5em;
} }
.video-wrapper { .video-wrapper {
@ -109,3 +115,17 @@ li.menu-selected {
display: flex; display: flex;
height: 100%; height: 100%;
} }
.spinner {
border: 4px solid var(--menu-header-text);
border-top: 4px solid var(--menu-selected-color);
border-radius: 50%;
width: 10px;
height: 10px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,4 +1,4 @@
import React, { ComponentProps } from "react" import React, { ComponentProps, useRef, useEffect, useState } from "react"
import { MinistreamApiClient } from "./api" import { MinistreamApiClient } from "./api"
import { resolve } from "path" import { resolve } from "path"
@ -7,141 +7,159 @@ type MediaContainerProps = {
api: MinistreamApiClient api: MinistreamApiClient
} }
type MediaContainerState = { export function MediaContainer({ selectedStream, api }: MediaContainerProps) {
iceReady: boolean const [iceReady, setICEReady] = useState(false)
remoteReady: boolean const [remoteReady, setRemoteReady] = useState(false)
videoReady: boolean const [pc, setPC] = useState(new RTCPeerConnection())
} const [ms, setMs] = useState(new MediaStream())
export class MediaContainer extends React.Component<MediaContainerProps, MediaContainerState> { // if selected stream changed, recreate pc, and set all to not ready.
api: MinistreamApiClient useEffect(() => {
pc: RTCPeerConnection console.log("effect: selectedStream")
ms: MediaStream setPC(new RTCPeerConnection({
constructor(props: MediaContainerProps) {
super(props)
this.api = props.api
this.state = {
iceReady: false,
remoteReady: false,
videoReady: false
}
this.pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }] iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
}) }))
this.pc.addTransceiver("video") setICEReady(false)
this.pc.addTransceiver("audio") setRemoteReady(false)
setMs(new MediaStream())
this.ms = new MediaStream() }, [selectedStream])
this.pc.ontrack = (event) => { useEffect(() => {
console.log("Got track(s).") console.log("effect: pc")
pc.addTransceiver("video")
event.streams.forEach(st => { pc.addTransceiver("audio")
st.getTracks().forEach(t => { pc.ontrack = (event) => {
this.ms.addTrack(t) event.streams.forEach((st) => st.getTracks().forEach((track) => {
}) ms.addTrack(track)
}) }))
} }
pc.onicecandidate = (event) => {
this.pc.oniceconnectionstatechange = () => {
console.log("ICE state changed to " + this.pc?.iceConnectionState)
}
}
async componentDidMount() {
this.pc.onicecandidate = (event) => {
if (!event.candidate) { if (!event.candidate) {
console.log("ICE gathering complete.") console.log("ICE gathering complete.")
this.setState((state) => { setICEReady(true)
return { iceReady: true, remoteReady: state.remoteReady }
})
} else { } else {
console.log("Adding ICE candidate: " + event.candidate) console.log("Adding ICE candidate: " + event.candidate)
} }
} }
const offer = await this.pc?.createOffer()
await this.pc?.setLocalDescription(offer) pc.oniceconnectionstatechange = () => {
console.log("Set local description.") console.log("ICE state changed to " + pc.iceConnectionState)
} }
async componentDidUpdate() { pc.createOffer().then((offer) => {
if (!this.state.iceReady) { pc.setLocalDescription(offer).then(() => { console.log("Local description set.") })
return
}
if (!this.props.selectedStream) {
return
}
if (!this.state.remoteReady) {
console.log("Setting up remote for " + this.props.selectedStream + ".")
const remote = await this.setupRemote()
if (remote) {
this.pc.setRemoteDescription(remote)
console.log("Set up remote")
this.setState((state) => {
return { iceReady: state.iceReady, remoteReady: true }
}) })
} else { }, [pc])
console.log("Could not set remote")
}
}
}
private setupRemote() { useEffect(() => {
if (!this.props.selectedStream) { console.log("effect: iceReady")
if (!iceReady || !selectedStream) {
return return
} }
const localOfferSdp = this.pc?.localDescription?.sdp const localOfferSdp = pc.localDescription?.sdp
if (!localOfferSdp) { if (!localOfferSdp) {
console.log("Could not get local description") console.log("Unable to get local description")
return return
} }
const localOffer = new RTCSessionDescription({ type: "offer", sdp: localOfferSdp }) const localOffer = new RTCSessionDescription({ type: "offer", sdp: localOfferSdp })
return this.api.postOffer(this.props.selectedStream, localOffer) api.postOffer(selectedStream, localOffer).then((remote) => {
pc.setRemoteDescription(remote).then(() => {
setRemoteReady(true)
})
})
}, [iceReady])
useEffect(() => {
console.log("effect: remoteReady")
if (!iceReady || !selectedStream || !remoteReady) {
return
}
}, [remoteReady])
const getElement = () => {
if (!selectedStream) {
return <LoadingText spinner={true} text="Waiting for stream selection." />
}
if (!iceReady) {
return <LoadingText spinner={true} text="Waiting for ICE gathering." />
}
if (!remoteReady) {
return <LoadingText spinner={true} text="Waiting for remote server." />
} }
renderVideo() {
if (!this.props.selectedStream) {
return <p>Waiting for stream selection.</p>
}
if (!this.state.iceReady) {
return <p>Waiting for ICE gathering to complete.</p>
}
if (!this.state.remoteReady) {
return <p>Waiting for remote.</p>
}
return ( return (
<> <div className="video-wrapper">
<p id="loading-text">Waiting for video to load.</p> <VideoPlayer ms={ms} />
<video ref={el => { </div>
if (el && this.ms) {
el.srcObject = this.ms
el.hidden = true
el.addEventListener('loadeddata', () => {
const t = document.getElementById("loading-text")
el.hidden = false
if (t) {
t.hidden = true
}
})
}
}} id="video" autoPlay muted controls />
</>
) )
} }
render() {
return ( return (
<div id="main"> <div id="main">
<main> <main>
<div className="video-wrapper"> {getElement()}
{this.renderVideo()}
</div>
</main> </main>
</div> </div>
) )
}
interface VideoPlayerProps {
ms: MediaStream
}
export function VideoPlayer({ ms }: VideoPlayerProps) {
const [ready, setReady] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (videoRef.current) {
if (videoRef.current) {
const ref = videoRef.current
ref.srcObject = ms
videoRef.current.addEventListener('loadeddata', () => {
ref.hidden = false
setReady(true)
})
}
}
})
if (ready) {
return (<video id="video" ref={videoRef} autoPlay muted controls />)
} else {
return (
<>
<video id="video" ref={videoRef} hidden autoPlay muted controls />
<LoadingText text="Waiting for video data." spinner={true} />
</>
)
} }
} }
interface LoadingText {
text: string
spinner?: boolean
}
export function LoadingText({ text, spinner = false }: LoadingText) {
var spinnerElement = (<></>)
if (spinner) {
spinnerElement = (<Spinner />)
}
return (
<>
<div className="loader">
{spinnerElement}
<p className="loading-text">{text}</p>
</div>
</>
)
}
export function Spinner() {
return <div className="spinner" />
}