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