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" /> | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user