Fix stream switching #3
| @@ -23,9 +23,15 @@ body { | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| #main p { | ||||
|     margin-top: 10em; | ||||
|     text-align: center; | ||||
| .loader { | ||||
|     display: flex; | ||||
| } | ||||
| .loader .spinner { | ||||
|     margin-top: 0px; | ||||
| } | ||||
| .loader .loading-text { | ||||
|     margin-top: 0px; | ||||
|     margin-left: 0.5em; | ||||
| } | ||||
|  | ||||
| .video-wrapper { | ||||
| @@ -108,4 +114,18 @@ li.menu-selected { | ||||
| #app { | ||||
|     display: flex; | ||||
|     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 { resolve } from "path" | ||||
|  | ||||
| @@ -7,141 +7,159 @@ type MediaContainerProps = { | ||||
|     api: MinistreamApiClient | ||||
| } | ||||
|  | ||||
| type MediaContainerState = { | ||||
|     iceReady: boolean | ||||
|     remoteReady: boolean | ||||
|     videoReady: boolean | ||||
| } | ||||
| export function MediaContainer({ selectedStream, api }: MediaContainerProps) { | ||||
|     const [iceReady, setICEReady] = useState(false) | ||||
|     const [remoteReady, setRemoteReady] = useState(false) | ||||
|     const [pc, setPC] = useState(new RTCPeerConnection()) | ||||
|     const [ms, setMs] = useState(new MediaStream()) | ||||
|  | ||||
| export class MediaContainer extends React.Component<MediaContainerProps, MediaContainerState> { | ||||
|     api: MinistreamApiClient | ||||
|     pc: RTCPeerConnection | ||||
|     ms: MediaStream | ||||
|  | ||||
|     constructor(props: MediaContainerProps) { | ||||
|         super(props) | ||||
|  | ||||
|         this.api = props.api | ||||
|         this.state = { | ||||
|             iceReady: false, | ||||
|             remoteReady: false, | ||||
|             videoReady: false | ||||
|         } | ||||
|  | ||||
|         this.pc = new RTCPeerConnection({ | ||||
|     // if selected stream changed, recreate pc, and set all to not ready. | ||||
|     useEffect(() => { | ||||
|         console.log("effect: selectedStream") | ||||
|         setPC(new RTCPeerConnection({ | ||||
|             iceServers: [{ urls: "stun:stun.l.google.com:19302" }] | ||||
|         }) | ||||
|         this.pc.addTransceiver("video") | ||||
|         this.pc.addTransceiver("audio") | ||||
|         })) | ||||
|         setICEReady(false) | ||||
|         setRemoteReady(false) | ||||
|         setMs(new MediaStream()) | ||||
|  | ||||
|         this.ms = new MediaStream() | ||||
|     }, [selectedStream]) | ||||
|  | ||||
|         this.pc.ontrack = (event) => { | ||||
|             console.log("Got track(s).") | ||||
|  | ||||
|             event.streams.forEach(st => { | ||||
|                 st.getTracks().forEach(t => { | ||||
|                     this.ms.addTrack(t) | ||||
|                 }) | ||||
|             }) | ||||
|     useEffect(() => { | ||||
|         console.log("effect: pc") | ||||
|         pc.addTransceiver("video") | ||||
|         pc.addTransceiver("audio") | ||||
|         pc.ontrack = (event) => { | ||||
|             event.streams.forEach((st) => st.getTracks().forEach((track) => { | ||||
|                 ms.addTrack(track) | ||||
|             })) | ||||
|         } | ||||
|  | ||||
|  | ||||
|         this.pc.oniceconnectionstatechange = () => { | ||||
|             console.log("ICE state changed to " + this.pc?.iceConnectionState) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async componentDidMount() { | ||||
|         this.pc.onicecandidate = (event) => { | ||||
|         pc.onicecandidate = (event) => { | ||||
|             if (!event.candidate) { | ||||
|                 console.log("ICE gathering complete.") | ||||
|                 this.setState((state) => { | ||||
|                     return { iceReady: true, remoteReady: state.remoteReady } | ||||
|                 }) | ||||
|                 setICEReady(true) | ||||
|             } else { | ||||
|                 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() { | ||||
|         if (!this.state.iceReady) { | ||||
|             return | ||||
|         pc.oniceconnectionstatechange = () => { | ||||
|             console.log("ICE state changed to " + pc.iceConnectionState) | ||||
|         } | ||||
|         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() { | ||||
|         if (!this.props.selectedStream) { | ||||
|         pc.createOffer().then((offer) => { | ||||
|             pc.setLocalDescription(offer).then(() => { console.log("Local description set.") }) | ||||
|         }) | ||||
|     }, [pc]) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         console.log("effect: iceReady") | ||||
|         if (!iceReady || !selectedStream) { | ||||
|             return | ||||
|         } | ||||
|         const localOfferSdp = this.pc?.localDescription?.sdp | ||||
|         const localOfferSdp = pc.localDescription?.sdp | ||||
|         if (!localOfferSdp) { | ||||
|             console.log("Could not get local description") | ||||
|             console.log("Unable to get local description") | ||||
|             return | ||||
|         } | ||||
|         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() { | ||||
|         if (!this.props.selectedStream) { | ||||
|             return <p>Waiting for stream selection.</p> | ||||
|     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." /> | ||||
|         } | ||||
|         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 ( | ||||
|             <div id="main"> | ||||
|                 <main> | ||||
|                     <div className="video-wrapper"> | ||||
|                         {this.renderVideo()} | ||||
|                     </div> | ||||
|                 </main> | ||||
|             <div className="video-wrapper"> | ||||
|                 <VideoPlayer ms={ms} /> | ||||
|             </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" /> | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user