Skip to content

Commit 9756d9d

Browse files
committed
Convert from JS to TS.
1 parent de7e6cc commit 9756d9d

19 files changed

Lines changed: 1302 additions & 1158 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ broadcast-box
2121
npm-debug.log*
2222
yarn-debug.log*
2323
yarn-error.log*
24+
25+
# rider
26+
/.idea

web/package-lock.json

Lines changed: 737 additions & 729 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@
3535
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
3636
"@tailwindcss/postcss": "^4.0.13",
3737
"@tailwindcss/vite": "^4.0.12",
38+
"@types/react": "^19.1.6",
39+
"@types/react-dom": "^19.1.5",
3840
"@vitejs/plugin-react": "^4.3.4",
3941
"postcss": "^8.5.3",
4042
"react-router-dom": "^7.3.0",
4143
"tailwindcss": "^4.0.7",
44+
"typescript": "^5.8.3",
4245
"vite": "^6.2.1"
4346
}
4447
}

web/src/App.jsx

Lines changed: 0 additions & 21 deletions
This file was deleted.

web/src/App.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react'
2+
import { Routes, Route } from 'react-router-dom'
3+
4+
import RootWrapper from './components/rootWrapper/rootWrapper'
5+
import Frontpage from "./components/selection/frontpage";
6+
import BrowserBroadcaster from "./components/broadcast/Broadcast";
7+
import PlayerPage from "./components/player/PlayerPage";
8+
9+
function App() {
10+
return (
11+
<Routes>
12+
<Route path='/' element={<RootWrapper />}>
13+
<Route index element={<Frontpage/>} />
14+
<Route path='/publish/*' element={<BrowserBroadcaster />} />
15+
<Route path='/*' element={<PlayerPage/>}
16+
/>
17+
</Route>
18+
</Routes>
19+
)
20+
}
21+
22+
export default App
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, {useEffect, useRef, useState} from 'react'
2+
import {useLocation} from 'react-router-dom'
3+
import ErrorHeader from '../error-header/errorHeader'
4+
5+
const mediaOptions = {
6+
audio: true,
7+
video: true
8+
}
9+
10+
enum ErrorMessageEnum {
11+
NoMediaDevices,
12+
NotAllowedError,
13+
NotFoundError
14+
}
15+
16+
function getMediaErrorMessage(value: ErrorMessageEnum): string {
17+
switch (value) {
18+
case ErrorMessageEnum.NoMediaDevices:
19+
return `MediaDevices API was not found. Publishing in Broadcast Box requires HTTPS 👮`;
20+
case ErrorMessageEnum.NotFoundError:
21+
return `Seems like you don't have camera 😭 Or you just blocked access to it...\nCheck camera settings, browser permissions and system permissions.`;
22+
case ErrorMessageEnum.NotAllowedError:
23+
return `You can't publish stream using your camera, because you have blocked access to it 😞`;
24+
default:
25+
return "Could not access your media device";
26+
}
27+
}
28+
29+
function BrowserBroadcaster() {
30+
const videoRef = useRef<HTMLVideoElement>(null)
31+
const location = useLocation()
32+
const [mediaAccessError, setMediaAccessError] = useState<ErrorMessageEnum | null>(null)
33+
const [publishSuccess, setPublishSuccess] = useState(false)
34+
const [useDisplayMedia, setUseDisplayMedia] = useState(false)
35+
const [peerConnectionDisconnected, setPeerConnectionDisconnected] = useState(false)
36+
37+
const apiPath = import.meta.env.VITE_API_PATH;
38+
39+
useEffect(() => {
40+
const peerConnection = new RTCPeerConnection()
41+
let stream: MediaStream | undefined = undefined;
42+
43+
if (!navigator.mediaDevices) {
44+
setMediaAccessError(ErrorMessageEnum.NoMediaDevices);
45+
return
46+
}
47+
48+
const mediaPromise = useDisplayMedia ?
49+
navigator.mediaDevices.getDisplayMedia(mediaOptions) :
50+
navigator.mediaDevices.getUserMedia(mediaOptions)
51+
52+
mediaPromise.then(mediaStream => {
53+
if (peerConnection.connectionState === "closed") {
54+
mediaStream
55+
.getTracks()
56+
.forEach(mediaStreamTrack => mediaStreamTrack.stop())
57+
58+
return;
59+
}
60+
61+
stream = mediaStream
62+
videoRef.current!.srcObject = mediaStream
63+
64+
mediaStream
65+
.getTracks()
66+
.forEach(mediaStreamTrack => {
67+
if (mediaStreamTrack.kind === 'audio') {
68+
peerConnection.addTransceiver(mediaStreamTrack, {
69+
direction: 'sendonly'
70+
})
71+
} else {
72+
peerConnection.addTransceiver(mediaStreamTrack, {
73+
direction: 'sendonly',
74+
sendEncodings: [
75+
{
76+
rid: 'high'
77+
},
78+
{
79+
rid: 'med',
80+
scaleResolutionDownBy: 2.0
81+
},
82+
{
83+
rid: 'low',
84+
scaleResolutionDownBy: 4.0
85+
}
86+
]
87+
})
88+
}
89+
})
90+
91+
peerConnection.oniceconnectionstatechange = () => {
92+
if (peerConnection.iceConnectionState === 'connected' || peerConnection.iceConnectionState === 'completed') {
93+
setPublishSuccess(true)
94+
setPeerConnectionDisconnected(false)
95+
} else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
96+
setPublishSuccess(false)
97+
setPeerConnectionDisconnected(true)
98+
}
99+
}
100+
101+
peerConnection
102+
.createOffer()
103+
.then(offer => {
104+
peerConnection.setLocalDescription(offer)
105+
.catch((err) => console.error(err));
106+
107+
fetch(`${apiPath}/whip`, {
108+
method: 'POST',
109+
body: offer.sdp,
110+
headers: {
111+
Authorization: `Bearer ${location.pathname.split('/').pop()}`,
112+
'Content-Type': 'application/sdp'
113+
}
114+
}).then(r => r.text())
115+
.then(answer => {
116+
peerConnection.setRemoteDescription({
117+
sdp: answer,
118+
type: 'answer'
119+
})
120+
.catch((err) => console.error(err))
121+
})
122+
})
123+
}, setMediaAccessError)
124+
125+
return function cleanup() {
126+
peerConnection.close()
127+
if (stream) {
128+
stream
129+
.getTracks()
130+
.forEach((streamTrack: MediaStreamTrack) => streamTrack.stop())
131+
}
132+
}
133+
}, [videoRef, useDisplayMedia, location.pathname])
134+
135+
return (
136+
<div className='container mx-auto'>
137+
{mediaAccessError != null && <ErrorHeader> {getMediaErrorMessage(mediaAccessError)} </ErrorHeader>}
138+
{peerConnectionDisconnected && <ErrorHeader> WebRTC has disconnected or failed to connect at all 😭 </ErrorHeader>}
139+
{publishSuccess && <PublishSuccess/>}
140+
141+
<video
142+
ref={videoRef}
143+
autoPlay
144+
muted
145+
controls
146+
playsInline
147+
className='w-full h-full'
148+
/>
149+
150+
<button
151+
onClick={() => setUseDisplayMedia(!useDisplayMedia)}
152+
className="appearance-none border w-full mt-5 py-2 px-3 leading-tight focus:outline-hidden focus:shadow-outline bg-gray-700 border-gray-700 text-white rounded-sm shadow-md placeholder-gray-200">
153+
{!useDisplayMedia && <> Publish Screen/Window/Tab instead </>}
154+
{useDisplayMedia && <> Publish Webcam instead </>}
155+
</button>
156+
</div>
157+
)
158+
}
159+
160+
function PublishSuccess() {
161+
const subscribeUrl = window.location.href.replace('publish/', '')
162+
163+
return (
164+
<p className={'bg-green-800 text-white text-lg text-center p-5 rounded-t-lg whitespace-pre-wrap'}>
165+
Live: Currently streaming to <a href={subscribeUrl} target="_blank" rel="noreferrer" className="hover:underline">{subscribeUrl}</a>
166+
</p>
167+
)
168+
}
169+
170+
export default BrowserBroadcaster
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from "react";
2+
3+
interface ErrorHeaderProps {
4+
children: React.ReactNode;
5+
}
6+
7+
const ErrorHeader = (props: ErrorHeaderProps) => {
8+
return (
9+
<p className={'bg-red-700 text-white text-lg text-center p-4 rounded-t-lg whitespace-pre-wrap'}>
10+
{props.children}
11+
</p>
12+
)
13+
}
14+
15+
export default ErrorHeader;

web/src/components/error-header/index.jsx

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, {Dispatch, SetStateAction, useEffect, useMemo, useState} from "react";
2+
import {useSearchParams} from "react-router-dom";
3+
4+
interface CinemaModeProviderContextProps{
5+
cinemaMode: boolean;
6+
setCinemaMode: Dispatch<SetStateAction<boolean>>;
7+
toggleCinemaMode: () => void;
8+
}
9+
10+
export const CinemaModeContext = React.createContext<CinemaModeProviderContextProps>({
11+
cinemaMode: false,
12+
setCinemaMode: () => {},
13+
toggleCinemaMode: () => {}
14+
});
15+
16+
interface CinemaModeProviderProps {
17+
children: React.ReactNode;
18+
}
19+
20+
export function CinemaModeProvider(props: CinemaModeProviderProps) {
21+
const [searchParams] = useSearchParams();
22+
const cinemaModeInUrl = searchParams.get("cinemaMode") === "true"
23+
const [cinemaMode, setCinemaMode] = useState(() => cinemaModeInUrl || localStorage.getItem("cinema-mode") === "true")
24+
25+
const state = useMemo<CinemaModeProviderContextProps>(() => ({
26+
cinemaMode: cinemaMode,
27+
setCinemaMode: setCinemaMode,
28+
toggleCinemaMode: () => setCinemaMode((prev) => !prev)
29+
}), [cinemaMode]);
30+
31+
useEffect(() => localStorage.setItem("cinema-mode", cinemaMode ? "true" : "false"), [cinemaMode]);
32+
return (
33+
<CinemaModeContext.Provider value={state}>
34+
{props.children}
35+
</CinemaModeContext.Provider>
36+
);
37+
}

0 commit comments

Comments
 (0)