Compare commits
	
		
			3 Commits
		
	
	
		
			bbd3adc4f7
			...
			8122264d59
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8122264d59 | |||
| 3fd391efc7 | |||
| dc5d75a2dd | 
							
								
								
									
										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,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>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										115
									
								
								src/js/api.ts
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								src/js/api.ts
									
									
									
									
									
								
							@@ -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[]> {
 | 
				
			||||||
 | 
					    let data: StreamInfo[] = [];
 | 
				
			||||||
 | 
					    let url = "/whip";
 | 
				
			||||||
 | 
					    if (this.ENV !== "production") {
 | 
				
			||||||
 | 
					      url = "http://localhost:8080/whip";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async listStreams(): Promise<StreamInfo[]> {
 | 
					    const resp = await fetch(url);
 | 
				
			||||||
        var data: StreamInfo[] = [];
 | 
					    data = (await resp.json()) as unknown as StreamInfo[];
 | 
				
			||||||
        var url = "/whip"
 | 
					 | 
				
			||||||
        if (this.ENV !== "production") {
 | 
					 | 
				
			||||||
            url = "http://localhost:8080/whip"
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try {
 | 
					    const sortedStreams = data.sort((a, b) => {
 | 
				
			||||||
            const resp = await fetch(
 | 
					      if (a.streamKey > b.streamKey) {
 | 
				
			||||||
                url,
 | 
					        return -1;
 | 
				
			||||||
            );
 | 
					      } else if (a.streamKey < b.streamKey) {
 | 
				
			||||||
            data = await resp.json() as unknown as StreamInfo[];
 | 
					        return 1;
 | 
				
			||||||
        }
 | 
					      }
 | 
				
			||||||
        catch (e) {
 | 
					      return 0;
 | 
				
			||||||
            throw e;
 | 
					    });
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        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, {
 | 
				
			||||||
        var 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, {
 | 
				
			||||||
        var 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
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										140
									
								
								src/js/app.tsx
									
									
									
									
									
								
							
							
						
						
									
										140
									
								
								src/js/app.tsx
									
									
									
									
									
								
							@@ -1,99 +1,97 @@
 | 
				
			|||||||
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 = () => {
 | 
				
			||||||
 | 
					    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,61 +1,55 @@
 | 
				
			|||||||
 | 
					function levelToNumber(level: Level): number {
 | 
				
			||||||
 | 
					  switch (level) {
 | 
				
			||||||
 | 
					    case "DEBUG":
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    case "INFO":
 | 
				
			||||||
 | 
					      return 1;
 | 
				
			||||||
 | 
					    case "ERROR":
 | 
				
			||||||
 | 
					      return 2;
 | 
				
			||||||
 | 
					    case "WARN":
 | 
				
			||||||
 | 
					      return 3;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let currentLevel: Level = "INFO";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if (process.env.NODE_ENV !== "production") {
 | 
				
			||||||
 | 
					  currentLevel = "DEBUG";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export namespace Log {
 | 
					export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG";
 | 
				
			||||||
    var currentLevel: Level = "INFO"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (process.env.NODE_ENV !== 'production') { 
 | 
					export function setLevel(level: Level) {
 | 
				
			||||||
        currentLevel = "DEBUG"
 | 
					  currentLevel = level;
 | 
				
			||||||
    }
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    export type Level = "ERROR" | "WARN" | "INFO" | "DEBUG"
 | 
					export interface LogArgs {
 | 
				
			||||||
 | 
					  [key: string]: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    export function setLevel(level: Level) {
 | 
					const doLog = (level: Level, message: string, extras?: LogArgs) => {
 | 
				
			||||||
        currentLevel = level
 | 
					  let logLine = `[${level}] ${message}`;
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (extras) {
 | 
				
			||||||
 | 
					    Object.keys(extras).forEach((key) => {
 | 
				
			||||||
 | 
					      logLine = logLine + ` ${key}=${extras[key]}`;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    export interface LogArgs {
 | 
					  if (levelToNumber(level) >= levelToNumber(currentLevel)) {
 | 
				
			||||||
        [key: string]: string
 | 
					    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);
 | 
				
			||||||
    }
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
    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
 | 
					 | 
				
			||||||
            case "INFO":
 | 
					 | 
				
			||||||
                return 1
 | 
					 | 
				
			||||||
            case "ERROR":
 | 
					 | 
				
			||||||
                return 2
 | 
					 | 
				
			||||||
            case "WARN":
 | 
					 | 
				
			||||||
                return 3
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										283
									
								
								src/js/media.tsx
									
									
									
									
									
								
							
							
						
						
									
										283
									
								
								src/js/media.tsx
									
									
									
									
									
								
							@@ -1,172 +1,179 @@
 | 
				
			|||||||
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);
 | 
				
			||||||
 | 
					    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) {
 | 
				
			||||||
    var 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" />;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										100
									
								
								src/js/menu.tsx
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								src/js/menu.tsx
									
									
									
									
									
								
							@@ -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 (
 | 
					 | 
				
			||||||
            <>
 | 
					 | 
				
			||||||
                {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