Linter and formatter #6
							
								
								
									
										7
									
								
								.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /* eslint-env node */ | ||||
| module.exports = { | ||||
|   extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], | ||||
|   parser: "@typescript-eslint/parser", | ||||
|   plugins: ["@typescript-eslint"], | ||||
|   root: true, | ||||
| }; | ||||
							
								
								
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| # First prettier run | ||||
| 3fd391efc774007b6f4fb3c92a82c6bf0efce207 | ||||
							
								
								
									
										2
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| dist/ | ||||
| node_modules/ | ||||
							
								
								
									
										1
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {} | ||||
							
								
								
									
										1263
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1263
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -13,7 +13,11 @@ | ||||
|   "author": "", | ||||
|   "license": "ISC", | ||||
|   "devDependencies": { | ||||
|     "@typescript-eslint/eslint-plugin": "^6.13.2", | ||||
|     "@typescript-eslint/parser": "^6.13.2", | ||||
|     "eslint": "^8.55.0", | ||||
|     "parcel": "^2.10.3", | ||||
|     "prettier": "^3.1.0", | ||||
|     "process": "^0.11.10", | ||||
|     "ts-loader": "^9.5.1", | ||||
|     "typescript": "^5.3.2" | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| @import url('https://fonts.googleapis.com/css2?family=Prompt:wght@300;600&display=swap'); | ||||
| @import url("https://fonts.googleapis.com/css2?family=Prompt:wght@300;600&display=swap"); | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| :root { | ||||
|     --menu-header-color: #1B1F22; | ||||
|     --menu-header-text: #FFEBF4; | ||||
|   --menu-header-color: #1b1f22; | ||||
|   --menu-header-text: #ffebf4; | ||||
|   --menu-background-color: #435058; | ||||
|     --menu-item-color: #4F5F69; | ||||
|   --menu-item-color: #4f5f69; | ||||
|   --menu-item-hover: #607480; | ||||
|     --menu-selected-color: #7F929F; | ||||
|   --menu-selected-color: #7f929f; | ||||
|   --main-background: #121517; | ||||
| } | ||||
|  | ||||
| @@ -19,7 +19,7 @@ body { | ||||
|   height: 100%; | ||||
|   background-color: var(--main-background); | ||||
|   color: var(--menu-header-text); | ||||
|     font-family: 'Prompt', sans-serif; | ||||
|   font-family: "Prompt", sans-serif; | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| @@ -83,7 +83,7 @@ ul.menu-list { | ||||
|  | ||||
| a.menu-link { | ||||
|   padding-left: 2em; | ||||
|     font-family: 'Prompt', sans-serif; | ||||
|   font-family: "Prompt", sans-serif; | ||||
|   font-weight: 300; | ||||
|   font-size: larger; | ||||
|   margin-right: auto; | ||||
| @@ -95,7 +95,7 @@ a.menu-link { | ||||
|   background-color: var(--menu-header-color); | ||||
|   color: var(--menu-header-text); | ||||
|   font-size: larger; | ||||
|     font-family: 'Prompt', sans-serif; | ||||
|   font-family: "Prompt", sans-serif; | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| @@ -114,7 +114,7 @@ a.menu-heading { | ||||
| } | ||||
|  | ||||
| .menu-viewcount { | ||||
|     font-family: 'Prompt', sans-serif; | ||||
|   font-family: "Prompt", sans-serif; | ||||
|   font-size: larger; | ||||
|   font-weight: 300; | ||||
|   color: var(--menu-header-text); | ||||
| @@ -134,7 +134,7 @@ li.menu-item:hover { | ||||
| } | ||||
|  | ||||
| li.menu-selected { | ||||
|     background-color: var(--menu-selected-color) | ||||
|   background-color: var(--menu-selected-color); | ||||
| } | ||||
|  | ||||
| #app { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|     <script src="./js/app.tsx" type="module"></script> | ||||
|     <link rel="stylesheet" href="./css/style.css" /> | ||||
|     <link rel="stylesheet" href="./css/fonts.css" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app" /> | ||||
|   | ||||
| @@ -1,82 +1,75 @@ | ||||
|  | ||||
| export interface SiteInfo { | ||||
|     siteName: string | ||||
|   siteName: string; | ||||
| } | ||||
|  | ||||
| export interface StreamInfo { | ||||
|     streamKey: string | ||||
|     viewCount: number | ||||
|   streamKey: string; | ||||
|   viewCount: number; | ||||
| } | ||||
|  | ||||
| export class MinistreamApiClient { | ||||
|     ENV: string | ||||
|   ENV: string; | ||||
|  | ||||
|   constructor() { | ||||
|     if (process.env.NODE_ENV) { | ||||
|             this.ENV = process.env.NODE_ENV | ||||
|       this.ENV = process.env.NODE_ENV; | ||||
|     } else { | ||||
|             this.ENV = "production" | ||||
|       this.ENV = "production"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async listStreams(): Promise<StreamInfo[]> { | ||||
|         var data: StreamInfo[] = []; | ||||
|         var url = "/whip" | ||||
|     let data: StreamInfo[] = []; | ||||
|     let url = "/whip"; | ||||
|     if (this.ENV !== "production") { | ||||
|             url = "http://localhost:8080/whip" | ||||
|       url = "http://localhost:8080/whip"; | ||||
|     } | ||||
|  | ||||
|         try { | ||||
|             const resp = await fetch( | ||||
|                 url, | ||||
|             ); | ||||
|             data = await resp.json() as unknown as StreamInfo[]; | ||||
|         } | ||||
|         catch (e) { | ||||
|             throw e; | ||||
|         } | ||||
|     const resp = await fetch(url); | ||||
|     data = (await resp.json()) as unknown as StreamInfo[]; | ||||
|  | ||||
|     const sortedStreams = data.sort((a, b) => { | ||||
|       if (a.streamKey > b.streamKey) { | ||||
|                 return -1 | ||||
|         return -1; | ||||
|       } else if (a.streamKey < b.streamKey) { | ||||
|                 return 1 | ||||
|         return 1; | ||||
|       } | ||||
|             return 0 | ||||
|         }) | ||||
|       return 0; | ||||
|     }); | ||||
|  | ||||
|     return sortedStreams; | ||||
|   } | ||||
|  | ||||
|     async postOffer(streamKey: string, offer_sdp: RTCSessionDescription): Promise<RTCSessionDescription> { | ||||
|         var url = "/whip/" + streamKey | ||||
|   async postOffer( | ||||
|     streamKey: string, | ||||
|     offer_sdp: RTCSessionDescription, | ||||
|   ): Promise<RTCSessionDescription> { | ||||
|     let url = "/whip/" + streamKey; | ||||
|     if (this.ENV !== "production") { | ||||
|             url = "http://localhost:8080/whip/" + streamKey | ||||
|       url = "http://localhost:8080/whip/" + streamKey; | ||||
|     } | ||||
|  | ||||
|         const resp = await fetch(url, | ||||
|             { | ||||
|     const resp = await fetch(url, { | ||||
|       method: "POST", | ||||
|                 body: offer_sdp.sdp | ||||
|             }) | ||||
|         const body = await resp.text() | ||||
|         const answer = new RTCSessionDescription({ type: "answer", sdp: body }) | ||||
|       body: offer_sdp.sdp, | ||||
|     }); | ||||
|     const body = await resp.text(); | ||||
|     const answer = new RTCSessionDescription({ type: "answer", sdp: body }); | ||||
|  | ||||
|         return answer | ||||
|     return answer; | ||||
|   } | ||||
|  | ||||
|   async siteInfo(): Promise<SiteInfo> { | ||||
|         var url = "/api/siteinfo" | ||||
|     let url = "/api/siteinfo"; | ||||
|     if (this.ENV !== "production") { | ||||
|             url = "http://localhost:8080/api/siteinfo" | ||||
|       url = "http://localhost:8080/api/siteinfo"; | ||||
|     } | ||||
|  | ||||
|         const resp = await fetch(url, | ||||
|             { | ||||
|     const resp = await fetch(url, { | ||||
|       method: "GET", | ||||
|             }) | ||||
|         const data = resp.json() | ||||
|     }); | ||||
|     const data = resp.json(); | ||||
|  | ||||
|         return data as unknown as SiteInfo | ||||
|     return data as unknown as SiteInfo; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,82 +1,80 @@ | ||||
| import { createRoot } from "react-dom/client"; | ||||
| import { Menu } from "./menu"; | ||||
| import { createContext, useEffect, useState } from "react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { MediaContainer } from "./media"; | ||||
| import { MinistreamApiClient, StreamInfo } from "./api"; | ||||
| import React from "react"; | ||||
| import { Log } from "./log"; | ||||
| import * as Log from "./log"; | ||||
|  | ||||
| interface AppProps { | ||||
|     api: MinistreamApiClient | ||||
|   api: MinistreamApiClient; | ||||
| } | ||||
|  | ||||
| const titleKey = "ministream.title" | ||||
| const titleKey = "ministream.title"; | ||||
|  | ||||
| function setTitleFromLocalstorage() { | ||||
|     var title = localStorage.getItem(titleKey) | ||||
|   const title = localStorage.getItem(titleKey); | ||||
|   if (title) { | ||||
|         setTitle(title) | ||||
|     setTitle(title); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function setTitle(title: string) { | ||||
|     const el = document.querySelector('title') | ||||
|   const el = document.querySelector("title"); | ||||
|   if (el) { | ||||
|         el.textContent = title | ||||
|     el.textContent = title; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function App({ api }: AppProps) { | ||||
|     const [streamList, setStreamList] = useState<StreamInfo[]>([]) | ||||
|     const [selectedStream, setSelectedStream] = useState<string | null>(null) | ||||
|   const [streamList, setStreamList] = useState<StreamInfo[]>([]); | ||||
|   const [selectedStream, setSelectedStream] = useState<string | null>(null); | ||||
|  | ||||
|   const updateSelect = (item: string | null) => { | ||||
|         setSelectedStream(item) | ||||
|     } | ||||
|  | ||||
|  | ||||
|     setSelectedStream(item); | ||||
|   }; | ||||
|  | ||||
|   const updateTitle = () => { | ||||
|     api.siteInfo().then((info) => { | ||||
|       if (info.siteName != document.title) { | ||||
|                 setTitle(info.siteName) | ||||
|                 localStorage.setItem(titleKey, info.siteName) | ||||
|         setTitle(info.siteName); | ||||
|         localStorage.setItem(titleKey, info.siteName); | ||||
|       } | ||||
|         }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     }); | ||||
|     return; | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const updateStreamList = () => { | ||||
|       api.listStreams().then((list) => { | ||||
|         setStreamList((current) => { | ||||
|           if (list.length != current.length) { | ||||
|                         Log.Debug("Updated streamList!") | ||||
|                         return list | ||||
|             Log.Debug("Updated streamList!"); | ||||
|             return list; | ||||
|           } | ||||
|                     if (!list.every((_, idx) => { | ||||
|                         return list[idx].streamKey === current[idx].streamKey | ||||
|                     })) { | ||||
|                         Log.Debug("Updated streamList..") | ||||
|                         return list | ||||
|                     } | ||||
|                     return current | ||||
|           if ( | ||||
|             !list.every((_, idx) => { | ||||
|               return list[idx].streamKey === current[idx].streamKey; | ||||
|             }) | ||||
|             }) | ||||
|  | ||||
|           ) { | ||||
|             Log.Debug("Updated streamList.."); | ||||
|             return list; | ||||
|           } | ||||
|           return current; | ||||
|         }); | ||||
|       }); | ||||
|     }; | ||||
|  | ||||
|         updateStreamList() | ||||
|     updateStreamList(); | ||||
|     const updateInterval = setInterval(() => { | ||||
|             updateStreamList() | ||||
|         }, 10000) | ||||
|         return () => clearInterval(updateInterval) | ||||
|     }, []) | ||||
|       updateStreamList(); | ||||
|     }, 10000); | ||||
|     return () => clearInterval(updateInterval); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|         updateTitle() | ||||
|     }, []) | ||||
|     updateTitle(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
| @@ -87,13 +85,13 @@ export function App({ api }: AppProps) { | ||||
|       /> | ||||
|       <MediaContainer selectedStream={selectedStream} api={api} /> | ||||
|     </> | ||||
|     ) | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const rootElement = document.getElementById("app"); | ||||
| setTitleFromLocalstorage() | ||||
| setTitleFromLocalstorage(); | ||||
| if (rootElement) { | ||||
|     const root = createRoot(rootElement) | ||||
|     const api = new MinistreamApiClient() | ||||
|     root.render(<App api={api} />) | ||||
|   const root = createRoot(rootElement); | ||||
|   const api = new MinistreamApiClient(); | ||||
|   root.render(<App api={api} />); | ||||
| } | ||||
| @@ -1,61 +1,55 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
| export namespace Log { | ||||
|     var currentLevel: Level = "INFO" | ||||
|  | ||||
|     if (process.env.NODE_ENV !== 'production') {  | ||||
|         currentLevel = "DEBUG" | ||||
|     } | ||||
|  | ||||
|     export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG" | ||||
|  | ||||
|     export function setLevel(level: Level) { | ||||
|         currentLevel = level | ||||
|     } | ||||
|  | ||||
|  | ||||
|     export interface LogArgs { | ||||
|         [key: string]: string | ||||
|     } | ||||
|  | ||||
|     export function Error(message: string, extras?: LogArgs) { | ||||
|         doLog("ERROR", message, extras) | ||||
|     } | ||||
|     export function Warn(message: string, extras?: LogArgs) { | ||||
|         doLog("WARN", message, extras) | ||||
|     } | ||||
|     export function Info(message: string, extras?: LogArgs) { | ||||
|         doLog("INFO", message, extras) | ||||
|     } | ||||
|     export function Debug(message: string, extras?: LogArgs) { | ||||
|         doLog("DEBUG", message, extras) | ||||
|     } | ||||
|  | ||||
|     function doLog(level: Level, message: string, extras?: LogArgs) { | ||||
|         var logLine = `[${level}] ${message}` | ||||
|  | ||||
|         if (extras) { | ||||
|             Object.keys(extras).forEach((key) => { | ||||
|                 logLine = logLine + ` ${key}=${extras[key]}` | ||||
|             }) | ||||
|         } | ||||
|  | ||||
|         if (levelToNumber(level) >= levelToNumber(currentLevel)) { | ||||
|             console.log(logLine) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| function levelToNumber(level: Level): number { | ||||
|   switch (level) { | ||||
|     case "DEBUG": | ||||
|                 return 0 | ||||
|       return 0; | ||||
|     case "INFO": | ||||
|                 return 1 | ||||
|       return 1; | ||||
|     case "ERROR": | ||||
|                 return 2 | ||||
|       return 2; | ||||
|     case "WARN": | ||||
|                 return 3 | ||||
|       return 3; | ||||
|   } | ||||
| } | ||||
|  | ||||
| let currentLevel: Level = "INFO"; | ||||
|  | ||||
| if (process.env.NODE_ENV !== "production") { | ||||
|   currentLevel = "DEBUG"; | ||||
| } | ||||
|  | ||||
| export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG"; | ||||
|  | ||||
| export function setLevel(level: Level) { | ||||
|   currentLevel = level; | ||||
| } | ||||
|  | ||||
| export interface LogArgs { | ||||
|   [key: string]: string; | ||||
| } | ||||
|  | ||||
| const doLog = (level: Level, message: string, extras?: LogArgs) => { | ||||
|   let logLine = `[${level}] ${message}`; | ||||
|  | ||||
|   if (extras) { | ||||
|     Object.keys(extras).forEach((key) => { | ||||
|       logLine = logLine + ` ${key}=${extras[key]}`; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (levelToNumber(level) >= levelToNumber(currentLevel)) { | ||||
|     console.log(logLine); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export function Error(message: string, extras?: LogArgs) { | ||||
|   doLog("ERROR", message, extras); | ||||
| } | ||||
| export function Warn(message: string, extras?: LogArgs) { | ||||
|   doLog("WARN", message, extras); | ||||
| } | ||||
| export function Info(message: string, extras?: LogArgs) { | ||||
|   doLog("INFO", message, extras); | ||||
| } | ||||
| export function Debug(message: string, extras?: LogArgs) { | ||||
|   doLog("DEBUG", message, extras); | ||||
| } | ||||
							
								
								
									
										165
									
								
								src/js/media.tsx
									
									
									
									
									
								
							
							
						
						
									
										165
									
								
								src/js/media.tsx
									
									
									
									
									
								
							| @@ -1,159 +1,166 @@ | ||||
| import { ComponentProps, useRef, useEffect, useState } from "react" | ||||
| import { MinistreamApiClient } from "./api" | ||||
| import { resolve } from "path" | ||||
| import React from "react" | ||||
| import { Log } from "./log" | ||||
| import { useRef, useEffect, useState } from "react"; | ||||
| import { MinistreamApiClient } from "./api"; | ||||
| import React from "react"; | ||||
| import * as Log from "./log"; | ||||
|  | ||||
| type MediaContainerProps = { | ||||
|     selectedStream: string | null | ||||
|     api: MinistreamApiClient | ||||
| } | ||||
|   selectedStream: string | null; | ||||
|   api: MinistreamApiClient; | ||||
| }; | ||||
|  | ||||
| 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()) | ||||
|   const [iceReady, setICEReady] = useState(false); | ||||
|   const [remoteReady, setRemoteReady] = useState(false); | ||||
|   const [pc, setPC] = useState(new RTCPeerConnection()); | ||||
|   const [ms, setMs] = useState(new MediaStream()); | ||||
|  | ||||
|   // if selected stream changed, recreate pc, and set all to not ready. | ||||
|   useEffect(() => { | ||||
|         Log.Debug("Ran useEffect.", { "because_state": "selectedStream" }) | ||||
|         setPC(new RTCPeerConnection({ | ||||
|             iceServers: [{ urls: "stun:stun.l.google.com:19302" }] | ||||
|         })) | ||||
|         setICEReady(false) | ||||
|         setRemoteReady(false) | ||||
|         setMs(new MediaStream()) | ||||
|  | ||||
|     }, [selectedStream]) | ||||
|     Log.Debug("Ran useEffect.", { because_state: "selectedStream" }); | ||||
|     setPC( | ||||
|       new RTCPeerConnection({ | ||||
|         iceServers: [{ urls: "stun:stun.l.google.com:19302" }], | ||||
|       }), | ||||
|     ); | ||||
|     setICEReady(false); | ||||
|     setRemoteReady(false); | ||||
|     setMs(new MediaStream()); | ||||
|   }, [selectedStream]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|         Log.Debug("Ran useEffect.", { "because_state": "pc" }) | ||||
|         pc.addTransceiver("video") | ||||
|         pc.addTransceiver("audio") | ||||
|     Log.Debug("Ran useEffect.", { because_state: "pc" }); | ||||
|     pc.addTransceiver("video"); | ||||
|     pc.addTransceiver("audio"); | ||||
|     pc.ontrack = (event) => { | ||||
|             event.streams.forEach((st) => st.getTracks().forEach((track) => { | ||||
|                 ms.addTrack(track) | ||||
|             })) | ||||
|         } | ||||
|       event.streams.forEach((st) => | ||||
|         st.getTracks().forEach((track) => { | ||||
|           ms.addTrack(track); | ||||
|         }), | ||||
|       ); | ||||
|     }; | ||||
|  | ||||
|     pc.onicecandidate = (event) => { | ||||
|       if (!event.candidate) { | ||||
|                 Log.Info("ICE gathering complete.") | ||||
|                 setICEReady(true) | ||||
|         Log.Info("ICE gathering complete."); | ||||
|         setICEReady(true); | ||||
|       } else { | ||||
|                 Log.Debug("Adding ICE candidate.", { "candidate": event.candidate.candidate }) | ||||
|             } | ||||
|         Log.Debug("Adding ICE candidate.", { | ||||
|           candidate: event.candidate.candidate, | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     pc.oniceconnectionstatechange = () => { | ||||
|             Log.Info("ICE gathering complete.") | ||||
|         } | ||||
|       Log.Info("ICE gathering complete."); | ||||
|     }; | ||||
|  | ||||
|     pc.createOffer().then((offer) => { | ||||
|             pc.setLocalDescription(offer).then(() => { Log.Debug("Local description set.") }) | ||||
|         }) | ||||
|     }, [pc]) | ||||
|       pc.setLocalDescription(offer).then(() => { | ||||
|         Log.Debug("Local description set."); | ||||
|       }); | ||||
|     }); | ||||
|   }, [pc]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|         Log.Debug("Ran useEffect", { "because_state": "iceReady" }) | ||||
|     Log.Debug("Ran useEffect", { because_state: "iceReady" }); | ||||
|     if (!iceReady || !selectedStream) { | ||||
|             return | ||||
|       return; | ||||
|     } | ||||
|         const localOfferSdp = pc.localDescription?.sdp | ||||
|     const localOfferSdp = pc.localDescription?.sdp; | ||||
|     if (!localOfferSdp) { | ||||
|             Log.Error("Unable to get local description.") | ||||
|             return | ||||
|       Log.Error("Unable to get local description."); | ||||
|       return; | ||||
|     } | ||||
|         const localOffer = new RTCSessionDescription({ type: "offer", sdp: localOfferSdp }) | ||||
|     const localOffer = new RTCSessionDescription({ | ||||
|       type: "offer", | ||||
|       sdp: localOfferSdp, | ||||
|     }); | ||||
|     api.postOffer(selectedStream, localOffer).then((remote) => { | ||||
|       pc.setRemoteDescription(remote).then(() => { | ||||
|                 setRemoteReady(true) | ||||
|             }) | ||||
|         }) | ||||
|     }, [iceReady]) | ||||
|         setRemoteReady(true); | ||||
|       }); | ||||
|     }); | ||||
|   }, [iceReady]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|         Log.Debug("Ran useEffect", { "because_state": "remoteReady" }) | ||||
|     Log.Debug("Ran useEffect", { because_state: "remoteReady" }); | ||||
|     if (!iceReady || !selectedStream || !remoteReady) { | ||||
|             return | ||||
|       return; | ||||
|     } | ||||
|     }, [remoteReady]) | ||||
|  | ||||
|   }, [remoteReady]); | ||||
|  | ||||
|   const getElement = () => { | ||||
|     if (!selectedStream) { | ||||
|             return <LoadingText spinner={true} text="Waiting for stream selection." /> | ||||
|       return ( | ||||
|         <LoadingText spinner={true} text="Waiting for stream selection." /> | ||||
|       ); | ||||
|     } | ||||
|     if (!iceReady) { | ||||
|             return <LoadingText spinner={true} text="Waiting for ICE gathering." /> | ||||
|       return <LoadingText spinner={true} text="Waiting for ICE gathering." />; | ||||
|     } | ||||
|     if (!remoteReady) { | ||||
|             return <LoadingText spinner={true} text="Waiting for remote server." /> | ||||
|       return <LoadingText spinner={true} text="Waiting for remote server." />; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className="video-wrapper"> | ||||
|         <VideoPlayer ms={ms} /> | ||||
|       </div> | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div id="main"> | ||||
|             <main> | ||||
|                 {getElement()} | ||||
|             </main> | ||||
|       <main>{getElement()}</main> | ||||
|     </div> | ||||
|     ) | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface VideoPlayerProps { | ||||
|     ms: MediaStream | ||||
|   ms: MediaStream; | ||||
| } | ||||
|  | ||||
| export function VideoPlayer({ ms }: VideoPlayerProps) { | ||||
|   const [ready, setReady] = useState(false); | ||||
|     const videoRef = useRef<HTMLVideoElement>(null) | ||||
|   const videoRef = useRef<HTMLVideoElement>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (ready) { | ||||
|             return | ||||
|       return; | ||||
|     } | ||||
|     if (videoRef.current) { | ||||
|       if (videoRef.current) { | ||||
|                 const ref = videoRef.current | ||||
|                 ref.srcObject = ms | ||||
|                 videoRef.current.addEventListener('loadeddata', () => { | ||||
|                     ref.hidden = false | ||||
|                     setReady(true) | ||||
|                 }) | ||||
|         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 />) | ||||
|     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 | ||||
|   text: string; | ||||
|   spinner?: boolean; | ||||
| } | ||||
|  | ||||
| export function LoadingText({ text, spinner = false }: LoadingText) { | ||||
|     var spinnerElement = (<></>) | ||||
|   let spinnerElement = <></>; | ||||
|   if (spinner) { | ||||
|         spinnerElement = (<Spinner />) | ||||
|     spinnerElement = <Spinner />; | ||||
|   } | ||||
|   return ( | ||||
|     <> | ||||
| @@ -164,9 +171,9 @@ export function LoadingText({ text, spinner = false }: LoadingText) { | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|     ) | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function Spinner() { | ||||
|     return <div className="spinner" /> | ||||
|   return <div className="spinner" />; | ||||
| } | ||||
|   | ||||
| @@ -1,53 +1,67 @@ | ||||
| import { createRoot } from "react-dom/client"; | ||||
| import React from "react"; | ||||
| import { StreamInfo } from "./api"; | ||||
|  | ||||
| type StreamSelectCallback = (selected: string | null) => void | ||||
| type StreamSelectCallback = (selected: string | null) => void; | ||||
|  | ||||
| interface MenuProps { | ||||
|     items: StreamInfo[] | ||||
|     selectedItem: string | null | ||||
|     selectCallback: StreamSelectCallback | ||||
|   items: StreamInfo[]; | ||||
|   selectedItem: string | null; | ||||
|   selectCallback: StreamSelectCallback; | ||||
| } | ||||
|  | ||||
| export function Menu({ items, selectedItem, selectCallback }: MenuProps) { | ||||
|     const title = document.title | ||||
|   const title = document.title; | ||||
|   const menuitems = () => { | ||||
|     return ( | ||||
|       <> | ||||
|         {items.map((value, idx) => { | ||||
|           if (selectedItem == value.streamKey) { | ||||
|                         return <> | ||||
|             return ( | ||||
|               <> | ||||
|                 <li key={idx} className="menu-item menu-selected"> | ||||
|                                 <a href={"#" + value} className="menu-link">{value.streamKey}</a> | ||||
|                   <a href={"#" + value} className="menu-link"> | ||||
|                     {value.streamKey} | ||||
|                   </a> | ||||
|                   <p className="menu-viewcount">{value.viewCount}</p> | ||||
|                 </li> | ||||
|               </> | ||||
|             ); | ||||
|           } else { | ||||
|                         return <> | ||||
|                             <li key={idx} onClick={() => { | ||||
|                                 selectCallback(value.streamKey) | ||||
|                             }} className="menu-item"> | ||||
|                                 <a href={"#" + value.streamKey} className="menu-link">{value.streamKey}</a> | ||||
|             return ( | ||||
|               <> | ||||
|                 <li | ||||
|                   key={idx} | ||||
|                   onClick={() => { | ||||
|                     selectCallback(value.streamKey); | ||||
|                   }} | ||||
|                   className="menu-item" | ||||
|                 > | ||||
|                   <a href={"#" + value.streamKey} className="menu-link"> | ||||
|                     {value.streamKey} | ||||
|                   </a> | ||||
|                   <p className="menu-viewcount">{value.viewCount}</p> | ||||
|                 </li> | ||||
|               </> | ||||
|             ); | ||||
|           } | ||||
|         })} | ||||
|       </> | ||||
|         ) | ||||
|     } | ||||
|     ); | ||||
|   }; | ||||
|   return ( | ||||
|     <div id="menu"> | ||||
|       <aside> | ||||
|                 <a className="menu-heading" onClick={() => { | ||||
|                     selectCallback(null) | ||||
|                 }} href="#">{title}</a> | ||||
|                 <ul className="menu-list"> | ||||
|                     {menuitems()} | ||||
|                 </ul> | ||||
|         <a | ||||
|           className="menu-heading" | ||||
|           onClick={() => { | ||||
|             selectCallback(null); | ||||
|           }} | ||||
|           href="#" | ||||
|         > | ||||
|           {title} | ||||
|         </a> | ||||
|         <ul className="menu-list">{menuitems()}</ul> | ||||
|       </aside> | ||||
|     </div> | ||||
|     ) | ||||
|  | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -4,19 +4,11 @@ | ||||
|     "jsx": "react", | ||||
|     "module": "esnext", | ||||
|     "moduleResolution": "node", | ||||
|         "lib": [ | ||||
|             "dom", | ||||
|             "esnext" | ||||
|         ], | ||||
|     "lib": ["dom", "esnext"], | ||||
|     "strict": true, | ||||
|     "sourceMap": true, | ||||
|         "target": "esnext", | ||||
|     "target": "esnext" | ||||
|   }, | ||||
|     "exclude": [ | ||||
|         "node_modules" | ||||
|     ], | ||||
|     "include": [ | ||||
|         "src/**/*.ts", | ||||
|         "src/**/*.tsx" | ||||
|     ], | ||||
|   "exclude": ["node_modules"], | ||||
|   "include": ["src/**/*.ts", "src/**/*.tsx"] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user