Fix stream switching #3
@ -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); }
|
||||||
|
}
|
238
src/js/media.tsx
238
src/js/media.tsx
@ -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)
|
|
||||||
console.log("Set local description.")
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidUpdate() {
|
pc.oniceconnectionstatechange = () => {
|
||||||
if (!this.state.iceReady) {
|
console.log("ICE state changed to " + pc.iceConnectionState)
|
||||||
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 {
|
|
||||||
console.log("Could not set remote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupRemote() {
|
pc.createOffer().then((offer) => {
|
||||||
if (!this.props.selectedStream) {
|
pc.setLocalDescription(offer).then(() => { console.log("Local description set.") })
|
||||||
|
})
|
||||||
|
}, [pc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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])
|
||||||
|
|
||||||
renderVideo() {
|
useEffect(() => {
|
||||||
if (!this.props.selectedStream) {
|
console.log("effect: remoteReady")
|
||||||
return <p>Waiting for stream selection.</p>
|
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." />
|
||||||
}
|
}
|
||||||
if (!this.state.iceReady) {
|
|
||||||
return <p>Waiting for ICE gathering to complete.</p>
|
|
||||||
}
|
|
||||||
if (!this.state.remoteReady) {
|
|
||||||
return <p>Waiting for remote.</p>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p id="loading-text">Waiting for video to load.</p>
|
|
||||||
<video ref={el => {
|
|
||||||
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 className="video-wrapper">
|
||||||
<main>
|
<VideoPlayer ms={ms} />
|
||||||
<div className="video-wrapper">
|
|
||||||
{this.renderVideo()}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="main">
|
||||||
|
<main>
|
||||||
|
{getElement()}
|
||||||
|
</main>
|
||||||
|
</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" />
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user