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