Linter and formatter #6
| @@ -1,7 +1,7 @@ | |||||||
| /* eslint-env node */ | /* eslint-env node */ | ||||||
| module.exports = { | module.exports = { | ||||||
|     extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], |   extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], | ||||||
|     parser: '@typescript-eslint/parser', |   parser: "@typescript-eslint/parser", | ||||||
|     plugins: ['@typescript-eslint'], |   plugins: ["@typescript-eslint"], | ||||||
|     root: true, |   root: true, | ||||||
|   }; | }; | ||||||
|   | |||||||
							
								
								
									
										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 @@ | |||||||
|  | {} | ||||||
							
								
								
									
										16
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -20,6 +20,7 @@ | |||||||
|         "@typescript-eslint/parser": "^6.13.2", |         "@typescript-eslint/parser": "^6.13.2", | ||||||
|         "eslint": "^8.55.0", |         "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" | ||||||
| @@ -4831,6 +4832,21 @@ | |||||||
|         "node": ">= 0.8.0" |         "node": ">= 0.8.0" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/prettier": { | ||||||
|  |       "version": "3.1.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", | ||||||
|  |       "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", | ||||||
|  |       "dev": true, | ||||||
|  |       "bin": { | ||||||
|  |         "prettier": "bin/prettier.cjs" | ||||||
|  |       }, | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">=14" | ||||||
|  |       }, | ||||||
|  |       "funding": { | ||||||
|  |         "url": "https://github.com/prettier/prettier?sponsor=1" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/process": { |     "node_modules/process": { | ||||||
|       "version": "0.11.10", |       "version": "0.11.10", | ||||||
|       "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", |       "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ | |||||||
|     "@typescript-eslint/parser": "^6.13.2", |     "@typescript-eslint/parser": "^6.13.2", | ||||||
|     "eslint": "^8.55.0", |     "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,162 +1,162 @@ | |||||||
| :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; | ||||||
| } | } | ||||||
|  |  | ||||||
| body { | body { | ||||||
|     overflow: hidden; |   overflow: hidden; | ||||||
|     height: 100vh; |   height: 100vh; | ||||||
|     margin: 0px; |   margin: 0px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #main { | #main { | ||||||
|     flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|     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; | ||||||
| } | } | ||||||
|  |  | ||||||
| .loader-wrapper { | .loader-wrapper { | ||||||
|     margin-top: 10em; |   margin-top: 10em; | ||||||
|     width: 100%; |   width: 100%; | ||||||
|     display: flex; |   display: flex; | ||||||
|     justify-content: center; |   justify-content: center; | ||||||
| } | } | ||||||
|  |  | ||||||
| .loader { | .loader { | ||||||
|     display: flex; |   display: flex; | ||||||
|     width: min-content; |   width: min-content; | ||||||
|     vertical-align: middle; |   vertical-align: middle; | ||||||
| } | } | ||||||
|  |  | ||||||
| .loader .spinner { | .loader .spinner { | ||||||
|     margin-top: auto; |   margin-top: auto; | ||||||
|     margin-bottom: auto; |   margin-bottom: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| .loader .loading-text { | .loader .loading-text { | ||||||
|     margin-top: auto; |   margin-top: auto; | ||||||
|     margin-bottom: auto; |   margin-bottom: auto; | ||||||
|     margin-left: 0.5em; |   margin-left: 0.5em; | ||||||
|     white-space: nowrap; |   white-space: nowrap; | ||||||
| } | } | ||||||
|  |  | ||||||
| .video-wrapper { | .video-wrapper { | ||||||
|     justify-content: left; |   justify-content: left; | ||||||
|     align-items: left; |   align-items: left; | ||||||
| } | } | ||||||
|  |  | ||||||
| video { | video { | ||||||
|     position: absolute; |   position: absolute; | ||||||
|     height: 100%; |   height: 100%; | ||||||
|     width: auto; |   width: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| main { | main { | ||||||
|     overflow-y: auto; |   overflow-y: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| aside { | aside { | ||||||
|     width: max-content; |   width: max-content; | ||||||
|     flex: 1 0 auto; |   flex: 1 0 auto; | ||||||
|     margin-left: 0px; |   margin-left: 0px; | ||||||
|     padding-left: 0px; |   padding-left: 0px; | ||||||
|     background-color: var(--menu-background-color); |   background-color: var(--menu-background-color); | ||||||
|     height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .menu-list { | .menu-list { | ||||||
|     list-style: none; |   list-style: none; | ||||||
|     margin-top: 0px; |   margin-top: 0px; | ||||||
| } | } | ||||||
|  |  | ||||||
| ul.menu-list { | ul.menu-list { | ||||||
|     padding-left: 0; |   padding-left: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| 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; | ||||||
| } | } | ||||||
|  |  | ||||||
| .menu-heading { | .menu-heading { | ||||||
|     padding-left: 1em; |   padding-left: 1em; | ||||||
|     padding-right: 1em; |   padding-right: 1em; | ||||||
|     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; | ||||||
| } | } | ||||||
|  |  | ||||||
| a.menu-heading { | a.menu-heading { | ||||||
|     text-decoration: none; |   text-decoration: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| .menu-item a { | .menu-item a { | ||||||
|     color: var(--menu-header-text); |   color: var(--menu-header-text); | ||||||
|     text-decoration: none; |   text-decoration: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| .menu-item { | .menu-item { | ||||||
|     cursor: pointer; |   cursor: pointer; | ||||||
|     display: flex; |   display: flex; | ||||||
| } | } | ||||||
|  |  | ||||||
| .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); | ||||||
|     margin-top: 0; |   margin-top: 0; | ||||||
|     margin-bottom: 0; |   margin-bottom: 0; | ||||||
|     padding-right: 1em; |   padding-right: 1em; | ||||||
|     height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| li.menu-item { | li.menu-item { | ||||||
|     background-color: var(--menu-item-color); |   background-color: var(--menu-item-color); | ||||||
|     margin-top: 1px; |   margin-top: 1px; | ||||||
| } | } | ||||||
|  |  | ||||||
| li.menu-item:hover { | li.menu-item:hover { | ||||||
|     background-color: var(--menu-item-hover); |   background-color: var(--menu-item-hover); | ||||||
| } | } | ||||||
|  |  | ||||||
| li.menu-selected { | li.menu-selected { | ||||||
|     background-color: var(--menu-selected-color) |   background-color: var(--menu-selected-color); | ||||||
| } | } | ||||||
|  |  | ||||||
| #app { | #app { | ||||||
|     display: flex; |   display: flex; | ||||||
|     height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .spinner { | .spinner { | ||||||
|     border: 4px solid var(--menu-header-text); |   border: 4px solid var(--menu-header-text); | ||||||
|     border-top: 4px solid var(--menu-selected-color); |   border-top: 4px solid var(--menu-selected-color); | ||||||
|     border-radius: 50%; |   border-radius: 50%; | ||||||
|     width: 10px; |   width: 10px; | ||||||
|     height: 10px; |   height: 10px; | ||||||
|     animation: spin 1s linear infinite; |   animation: spin 1s linear infinite; | ||||||
| } | } | ||||||
|  |  | ||||||
| @keyframes spin { | @keyframes spin { | ||||||
|     0% { |   0% { | ||||||
|         transform: rotate(0deg); |     transform: rotate(0deg); | ||||||
|     } |   } | ||||||
|  |  | ||||||
|     100% { |   100% { | ||||||
|         transform: rotate(360deg); |     transform: rotate(360deg); | ||||||
|     } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,14 +1,14 @@ | |||||||
| <!doctype html> | <!doctype html> | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="utf-8"/> |     <meta charset="utf-8" /> | ||||||
|     <title></title> |     <title></title> | ||||||
|     <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" /> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
							
								
								
									
										110
									
								
								src/js/api.ts
									
									
									
									
									
								
							
							
						
						
									
										110
									
								
								src/js/api.ts
									
									
									
									
									
								
							| @@ -1,77 +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[]> { | ||||||
|  |     let data: StreamInfo[] = []; | ||||||
|  |     let url = "/whip"; | ||||||
|  |     if (this.ENV !== "production") { | ||||||
|  |       url = "http://localhost:8080/whip"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async listStreams(): Promise<StreamInfo[]> { |     const resp = await fetch(url); | ||||||
|         let data: StreamInfo[] = []; |     data = (await resp.json()) as unknown as StreamInfo[]; | ||||||
|         let url = "/whip" |  | ||||||
|         if (this.ENV !== "production") { |  | ||||||
|             url = "http://localhost:8080/whip" |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const resp = await fetch( |     const sortedStreams = data.sort((a, b) => { | ||||||
|             url, |       if (a.streamKey > b.streamKey) { | ||||||
|         ); |         return -1; | ||||||
|         data = await resp.json() as unknown as StreamInfo[]; |       } else if (a.streamKey < b.streamKey) { | ||||||
|  |         return 1; | ||||||
|  |       } | ||||||
|  |       return 0; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|         const sortedStreams = data.sort((a, b) => { |     return sortedStreams; | ||||||
|             if (a.streamKey > b.streamKey) { |   } | ||||||
|                 return -1 |  | ||||||
|             } else if (a.streamKey < b.streamKey) { |  | ||||||
|                 return 1 |  | ||||||
|             } |  | ||||||
|             return 0 |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         return sortedStreams; |   async postOffer( | ||||||
|  |     streamKey: string, | ||||||
|  |     offer_sdp: RTCSessionDescription, | ||||||
|  |   ): Promise<RTCSessionDescription> { | ||||||
|  |     let url = "/whip/" + streamKey; | ||||||
|  |     if (this.ENV !== "production") { | ||||||
|  |       url = "http://localhost:8080/whip/" + streamKey; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async postOffer(streamKey: string, offer_sdp: RTCSessionDescription): Promise<RTCSessionDescription> { |     const resp = await fetch(url, { | ||||||
|         let url = "/whip/" + streamKey |       method: "POST", | ||||||
|         if (this.ENV !== "production") { |       body: offer_sdp.sdp, | ||||||
|             url = "http://localhost:8080/whip/" + streamKey |     }); | ||||||
|         } |     const body = await resp.text(); | ||||||
|  |     const answer = new RTCSessionDescription({ type: "answer", sdp: body }); | ||||||
|  |  | ||||||
|         const resp = await fetch(url, |     return answer; | ||||||
|             { |   } | ||||||
|                 method: "POST", |  | ||||||
|                 body: offer_sdp.sdp |  | ||||||
|             }) |  | ||||||
|         const body = await resp.text() |  | ||||||
|         const answer = new RTCSessionDescription({ type: "answer", sdp: body }) |  | ||||||
|  |  | ||||||
|         return answer |   async siteInfo(): Promise<SiteInfo> { | ||||||
|  |     let url = "/api/siteinfo"; | ||||||
|  |     if (this.ENV !== "production") { | ||||||
|  |       url = "http://localhost:8080/api/siteinfo"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async siteInfo(): Promise<SiteInfo> { |     const resp = await fetch(url, { | ||||||
|         let url = "/api/siteinfo" |       method: "GET", | ||||||
|         if (this.ENV !== "production") { |     }); | ||||||
|             url = "http://localhost:8080/api/siteinfo" |     const data = resp.json(); | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const resp = await fetch(url, |     return data as unknown as SiteInfo; | ||||||
|             { |   } | ||||||
|                 method: "GET", |  | ||||||
|             }) |  | ||||||
|         const data = resp.json() |  | ||||||
|  |  | ||||||
|         return data as unknown as SiteInfo |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										136
									
								
								src/js/app.tsx
									
									
									
									
									
								
							
							
						
						
									
										136
									
								
								src/js/app.tsx
									
									
									
									
									
								
							| @@ -7,93 +7,91 @@ import React from "react"; | |||||||
| import * as 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() { | ||||||
|     const 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 = () => { | ||||||
|  |     api.siteInfo().then((info) => { | ||||||
|  |       if (info.siteName != document.title) { | ||||||
|  |         setTitle(info.siteName); | ||||||
|  |         localStorage.setItem(titleKey, info.siteName); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     return; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|     const updateTitle = () => { |     const updateStreamList = () => { | ||||||
|         api.siteInfo().then((info) => { |       api.listStreams().then((list) => { | ||||||
|             if (info.siteName != document.title) { |         setStreamList((current) => { | ||||||
|                 setTitle(info.siteName) |           if (list.length != current.length) { | ||||||
|                 localStorage.setItem(titleKey, info.siteName) |             Log.Debug("Updated streamList!"); | ||||||
|             } |             return list; | ||||||
|         }) |           } | ||||||
|         return |           if ( | ||||||
|     } |             !list.every((_, idx) => { | ||||||
|  |               return list[idx].streamKey === current[idx].streamKey; | ||||||
|  |  | ||||||
|     useEffect(() => { |  | ||||||
|         const updateStreamList = () => { |  | ||||||
|             api.listStreams().then((list) => { |  | ||||||
|                 setStreamList((current) => { |  | ||||||
|                     if (list.length != current.length) { |  | ||||||
|                         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 |  | ||||||
|                 }) |  | ||||||
|             }) |             }) | ||||||
|  |           ) { | ||||||
|  |             Log.Debug("Updated streamList.."); | ||||||
|  |             return list; | ||||||
|  |           } | ||||||
|  |           return current; | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|         } |     updateStreamList(); | ||||||
|  |     const updateInterval = setInterval(() => { | ||||||
|  |       updateStreamList(); | ||||||
|  |     }, 10000); | ||||||
|  |     return () => clearInterval(updateInterval); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|         updateStreamList() |   useEffect(() => { | ||||||
|         const updateInterval = setInterval(() => { |     updateTitle(); | ||||||
|             updateStreamList() |   }, []); | ||||||
|         }, 10000) |  | ||||||
|         return () => clearInterval(updateInterval) |  | ||||||
|     }, []) |  | ||||||
|  |  | ||||||
|     useEffect(() => { |   return ( | ||||||
|         updateTitle() |     <> | ||||||
|     }, []) |       <Menu | ||||||
|  |         items={streamList} | ||||||
|     return ( |         selectedItem={selectedStream} | ||||||
|         <> |         selectCallback={updateSelect} | ||||||
|             <Menu |       /> | ||||||
|                 items={streamList} |       <MediaContainer selectedStream={selectedStream} api={api} /> | ||||||
|                 selectedItem={selectedStream} |     </> | ||||||
|                 selectCallback={updateSelect} |   ); | ||||||
|             /> |  | ||||||
|             <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,58 +1,55 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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" | let currentLevel: Level = "INFO"; | ||||||
|  |  | ||||||
| if (process.env.NODE_ENV !== 'production') { | if (process.env.NODE_ENV !== "production") { | ||||||
|     currentLevel = "DEBUG" |   currentLevel = "DEBUG"; | ||||||
| } | } | ||||||
|  |  | ||||||
| export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG" | export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG"; | ||||||
|  |  | ||||||
| export function setLevel(level: Level) { | export function setLevel(level: Level) { | ||||||
|     currentLevel = level |   currentLevel = level; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface LogArgs { | export interface LogArgs { | ||||||
|     [key: string]: string |   [key: string]: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| const doLog = (level: Level, message: string, extras?: LogArgs) => { | const doLog = (level: Level, message: string, extras?: LogArgs) => { | ||||||
|     let logLine = `[${level}] ${message}` |   let logLine = `[${level}] ${message}`; | ||||||
|  |  | ||||||
|     if (extras) { |   if (extras) { | ||||||
|         Object.keys(extras).forEach((key) => { |     Object.keys(extras).forEach((key) => { | ||||||
|             logLine = logLine + ` ${key}=${extras[key]}` |       logLine = logLine + ` ${key}=${extras[key]}`; | ||||||
|         }) |     }); | ||||||
|     } |   } | ||||||
|  |  | ||||||
|     if (levelToNumber(level) >= levelToNumber(currentLevel)) { |   if (levelToNumber(level) >= levelToNumber(currentLevel)) { | ||||||
|         console.log(logLine) |     console.log(logLine); | ||||||
|     } |   } | ||||||
| } | }; | ||||||
|  |  | ||||||
| export function Error(message: string, extras?: LogArgs) { | export function Error(message: string, extras?: LogArgs) { | ||||||
|     doLog("ERROR", message, extras) |   doLog("ERROR", message, extras); | ||||||
| } | } | ||||||
| export function Warn(message: string, extras?: LogArgs) { | export function Warn(message: string, extras?: LogArgs) { | ||||||
|     doLog("WARN", message, extras) |   doLog("WARN", message, extras); | ||||||
| } | } | ||||||
| export function Info(message: string, extras?: LogArgs) { | export function Info(message: string, extras?: LogArgs) { | ||||||
|     doLog("INFO", message, extras) |   doLog("INFO", message, extras); | ||||||
| } | } | ||||||
| export function Debug(message: string, extras?: LogArgs) { | export function Debug(message: string, extras?: LogArgs) { | ||||||
|     doLog("DEBUG", message, extras) |   doLog("DEBUG", message, extras); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										280
									
								
								src/js/media.tsx
									
									
									
									
									
								
							
							
						
						
									
										280
									
								
								src/js/media.tsx
									
									
									
									
									
								
							| @@ -1,171 +1,179 @@ | |||||||
| import { useRef, useEffect, useState } from "react" | import { useRef, useEffect, useState } from "react"; | ||||||
| import { MinistreamApiClient } from "./api" | import { MinistreamApiClient } from "./api"; | ||||||
| import React from "react" | import React from "react"; | ||||||
| import * as Log from "./log"; | import * as 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); | ||||||
|  |     setMs(new MediaStream()); | ||||||
|  |   }, [selectedStream]); | ||||||
|  |  | ||||||
|     }, [selectedStream]) |   useEffect(() => { | ||||||
|  |     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); | ||||||
|  |         }), | ||||||
|  |       ); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     useEffect(() => { |     pc.onicecandidate = (event) => { | ||||||
|         Log.Debug("Ran useEffect.", { "because_state": "pc" }) |       if (!event.candidate) { | ||||||
|         pc.addTransceiver("video") |         Log.Info("ICE gathering complete."); | ||||||
|         pc.addTransceiver("audio") |         setICEReady(true); | ||||||
|         pc.ontrack = (event) => { |       } else { | ||||||
|             event.streams.forEach((st) => st.getTracks().forEach((track) => { |         Log.Debug("Adding ICE candidate.", { | ||||||
|                 ms.addTrack(track) |           candidate: event.candidate.candidate, | ||||||
|             })) |         }); | ||||||
|         } |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|         pc.onicecandidate = (event) => { |     pc.oniceconnectionstatechange = () => { | ||||||
|             if (!event.candidate) { |       Log.Info("ICE gathering complete."); | ||||||
|                 Log.Info("ICE gathering complete.") |     }; | ||||||
|                 setICEReady(true) |  | ||||||
|             } else { |  | ||||||
|                 Log.Debug("Adding ICE candidate.", { "candidate": event.candidate.candidate }) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         pc.oniceconnectionstatechange = () => { |     pc.createOffer().then((offer) => { | ||||||
|             Log.Info("ICE gathering complete.") |       pc.setLocalDescription(offer).then(() => { | ||||||
|         } |         Log.Debug("Local description set."); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }, [pc]); | ||||||
|  |  | ||||||
|         pc.createOffer().then((offer) => { |   useEffect(() => { | ||||||
|             pc.setLocalDescription(offer).then(() => { Log.Debug("Local description set.") }) |     Log.Debug("Ran useEffect", { because_state: "iceReady" }); | ||||||
|         }) |     if (!iceReady || !selectedStream) { | ||||||
|     }, [pc]) |       return; | ||||||
|  |     } | ||||||
|  |     const localOfferSdp = pc.localDescription?.sdp; | ||||||
|  |     if (!localOfferSdp) { | ||||||
|  |       Log.Error("Unable to get local description."); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const localOffer = new RTCSessionDescription({ | ||||||
|  |       type: "offer", | ||||||
|  |       sdp: localOfferSdp, | ||||||
|  |     }); | ||||||
|  |     api.postOffer(selectedStream, localOffer).then((remote) => { | ||||||
|  |       pc.setRemoteDescription(remote).then(() => { | ||||||
|  |         setRemoteReady(true); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }, [iceReady]); | ||||||
|  |  | ||||||
|     useEffect(() => { |   useEffect(() => { | ||||||
|         Log.Debug("Ran useEffect", { "because_state": "iceReady" }) |     Log.Debug("Ran useEffect", { because_state: "remoteReady" }); | ||||||
|         if (!iceReady || !selectedStream) { |     if (!iceReady || !selectedStream || !remoteReady) { | ||||||
|             return |       return; | ||||||
|         } |     } | ||||||
|         const localOfferSdp = pc.localDescription?.sdp |   }, [remoteReady]); | ||||||
|         if (!localOfferSdp) { |  | ||||||
|             Log.Error("Unable to get local description.") |  | ||||||
|             return |  | ||||||
|         } |  | ||||||
|         const localOffer = new RTCSessionDescription({ type: "offer", sdp: localOfferSdp }) |  | ||||||
|         api.postOffer(selectedStream, localOffer).then((remote) => { |  | ||||||
|             pc.setRemoteDescription(remote).then(() => { |  | ||||||
|                 setRemoteReady(true) |  | ||||||
|             }) |  | ||||||
|         }) |  | ||||||
|     }, [iceReady]) |  | ||||||
|  |  | ||||||
|     useEffect(() => { |   const getElement = () => { | ||||||
|         Log.Debug("Ran useEffect", { "because_state": "remoteReady" }) |     if (!selectedStream) { | ||||||
|         if (!iceReady || !selectedStream || !remoteReady) { |       return ( | ||||||
|             return |         <LoadingText spinner={true} text="Waiting for stream selection." /> | ||||||
|         } |       ); | ||||||
|     }, [remoteReady]) |     } | ||||||
|  |     if (!iceReady) { | ||||||
|  |       return <LoadingText spinner={true} text="Waiting for ICE gathering." />; | ||||||
|     const getElement = () => { |     } | ||||||
|         if (!selectedStream) { |     if (!remoteReady) { | ||||||
|             return <LoadingText spinner={true} text="Waiting for stream selection." /> |       return <LoadingText spinner={true} text="Waiting for remote server." />; | ||||||
|         } |  | ||||||
|         if (!iceReady) { |  | ||||||
|             return <LoadingText spinner={true} text="Waiting for ICE gathering." /> |  | ||||||
|         } |  | ||||||
|         if (!remoteReady) { |  | ||||||
|             return <LoadingText spinner={true} text="Waiting for remote server." /> |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return ( |  | ||||||
|             <div className="video-wrapper"> |  | ||||||
|                 <VideoPlayer ms={ms} /> |  | ||||||
|             </div> |  | ||||||
|         ) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <div id="main"> |       <div className="video-wrapper"> | ||||||
|             <main> |         <VideoPlayer ms={ms} /> | ||||||
|                 {getElement()} |       </div> | ||||||
|             </main> |     ); | ||||||
|         </div> |   }; | ||||||
|     ) |  | ||||||
|  |   return ( | ||||||
|  |     <div id="main"> | ||||||
|  |       <main>{getElement()}</main> | ||||||
|  |     </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(() => { |  | ||||||
|         if (ready) { |  | ||||||
|             return |  | ||||||
|         } |  | ||||||
|         if (videoRef.current) { |  | ||||||
|             if (videoRef.current) { |  | ||||||
|                 const ref = videoRef.current |  | ||||||
|                 ref.srcObject = ms |  | ||||||
|                 videoRef.current.addEventListener('loadeddata', () => { |  | ||||||
|                     ref.hidden = false |  | ||||||
|                     setReady(true) |  | ||||||
|                 }) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     }) |  | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|     if (ready) { |     if (ready) { | ||||||
|         return (<video id="video" ref={videoRef} autoPlay muted controls />) |       return; | ||||||
|     } else { |  | ||||||
|         return ( |  | ||||||
|             <> |  | ||||||
|                 <video id="video" ref={videoRef} hidden autoPlay muted controls /> |  | ||||||
|                 <LoadingText text="Waiting for video data." spinner={true} /> |  | ||||||
|             </> |  | ||||||
|         ) |  | ||||||
|     } |     } | ||||||
|  |     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 { | interface LoadingText { | ||||||
|     text: string |   text: string; | ||||||
|     spinner?: boolean |   spinner?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| export function LoadingText({ text, spinner = false }: LoadingText) { | export function LoadingText({ text, spinner = false }: LoadingText) { | ||||||
|     let spinnerElement = (<></>) |   let spinnerElement = <></>; | ||||||
|     if (spinner) { |   if (spinner) { | ||||||
|         spinnerElement = (<Spinner />) |     spinnerElement = <Spinner />; | ||||||
|     } |   } | ||||||
|     return ( |   return ( | ||||||
|         <> |     <> | ||||||
|             <div className="loader-wrapper"> |       <div className="loader-wrapper"> | ||||||
|                 <div className="loader"> |         <div className="loader"> | ||||||
|                     {spinnerElement} |           {spinnerElement} | ||||||
|                     <p className="loading-text">{text}</p> |           <p className="loading-text">{text}</p> | ||||||
|                 </div> |         </div> | ||||||
|             </div> |       </div> | ||||||
|         </> |     </> | ||||||
|     ) |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function Spinner() { | export function Spinner() { | ||||||
|     return <div className="spinner" /> |   return <div className="spinner" />; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,52 +1,67 @@ | |||||||
| 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 ( |  | ||||||
|             <> |  | ||||||
|                 {items.map((value, idx) => { |  | ||||||
|                     if (selectedItem == value.streamKey) { |  | ||||||
|                         return <> |  | ||||||
|                             <li key={idx} className="menu-item menu-selected"> |  | ||||||
|                                 <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> |  | ||||||
|                                 <p className="menu-viewcount">{value.viewCount}</p> |  | ||||||
|                             </li> |  | ||||||
|                         </> |  | ||||||
|                     } |  | ||||||
|                 })} |  | ||||||
|             </> |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
|     return ( |     return ( | ||||||
|         <div id="menu"> |       <> | ||||||
|             <aside> |         {items.map((value, idx) => { | ||||||
|                 <a className="menu-heading" onClick={() => { |           if (selectedItem == value.streamKey) { | ||||||
|                     selectCallback(null) |             return ( | ||||||
|                 }} href="#">{title}</a> |               <> | ||||||
|                 <ul className="menu-list"> |                 <li key={idx} className="menu-item menu-selected"> | ||||||
|                     {menuitems()} |                   <a href={"#" + value} className="menu-link"> | ||||||
|                 </ul> |                     {value.streamKey} | ||||||
|             </aside> |                   </a> | ||||||
|         </div> |                   <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> | ||||||
|  |                   <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> | ||||||
|  |       </aside> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,22 +1,14 @@ | |||||||
| { | { | ||||||
|     "compilerOptions": { |   "compilerOptions": { | ||||||
|         "esModuleInterop": true, |     "esModuleInterop": true, | ||||||
|         "jsx": "react", |     "jsx": "react", | ||||||
|         "module": "esnext", |     "module": "esnext", | ||||||
|         "moduleResolution": "node", |     "moduleResolution": "node", | ||||||
|         "lib": [ |     "lib": ["dom", "esnext"], | ||||||
|             "dom", |     "strict": true, | ||||||
|             "esnext" |     "sourceMap": true, | ||||||
|         ], |     "target": "esnext" | ||||||
|         "strict": true, |   }, | ||||||
|         "sourceMap": true, |   "exclude": ["node_modules"], | ||||||
|         "target": "esnext", |   "include": ["src/**/*.ts", "src/**/*.tsx"] | ||||||
|     }, | } | ||||||
|     "exclude": [ |  | ||||||
|         "node_modules" |  | ||||||
|     ], |  | ||||||
|     "include": [ |  | ||||||
|         "src/**/*.ts", |  | ||||||
|         "src/**/*.tsx" |  | ||||||
|     ], |  | ||||||
| } |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user