├── web ├── postcss.config.mjs ├── public │ └── robots.txt ├── vite.config.mjs ├── src │ ├── components │ │ ├── playerHeader │ │ │ └── PlayerHeader.tsx │ │ ├── player │ │ │ ├── components │ │ │ │ ├── CurrentViewersComponent.tsx │ │ │ │ ├── VolumeComponent.tsx │ │ │ │ ├── PlayPauseComponent.tsx │ │ │ │ └── QualitySelectorComponent.tsx │ │ │ ├── PlayerPage.tsx │ │ │ └── Player.tsx │ │ ├── rootWrapper │ │ │ └── RootWrapper.tsx │ │ ├── selection │ │ │ ├── AvailableStreams.tsx │ │ │ └── Frontpage.tsx │ │ ├── shared │ │ │ └── ModalTextInput.tsx │ │ ├── statistics │ │ │ └── Statistics.tsx │ │ └── broadcast │ │ │ └── Broadcast.tsx │ ├── index.tsx │ ├── App.tsx │ ├── index.css │ └── providers │ │ ├── CinemaModeProvider.tsx │ │ └── StatusProvider.tsx ├── index.html ├── package.json └── tsconfig.json ├── .github ├── img │ ├── outputPage.png │ ├── broadcastView.png │ └── streamSettings.png ├── dependabot.yaml └── workflows │ ├── node-lint.yaml │ ├── go-test.yaml │ ├── go-lint.yaml │ └── build-container.yaml ├── .env.production ├── .env.development ├── internal ├── webrtc │ ├── whep_test.go │ ├── keyframe_detector.go │ ├── track_multi_codec.go │ ├── whip_test.go │ ├── whip.go │ ├── whep.go │ └── webrtc.go ├── webhook │ ├── webhook_test.go │ └── webhook.go └── networktest │ └── networktest.go ├── .gitignore ├── Dockerfile ├── examples ├── gstreamer-whep-to-rtmp.nu ├── gstreamer-broadcast.nu ├── webhook-server │ └── main.go ├── simple-watcher.html ├── dynamic-watcher.html └── recording │ └── main.go ├── docker-compose.yaml ├── LICENSE ├── go.mod ├── CONTRIBUTING.md ├── main.go ├── go.sum └── README.md /web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export const plugins = { 2 | '@tailwindcss/postcss': {}, 3 | }; 4 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.github/img/outputPage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Glimesh/broadcast-box/HEAD/.github/img/outputPage.png -------------------------------------------------------------------------------- /.github/img/broadcastView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Glimesh/broadcast-box/HEAD/.github/img/broadcastView.png -------------------------------------------------------------------------------- /.github/img/streamSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Glimesh/broadcast-box/HEAD/.github/img/streamSettings.png -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | HTTP_ADDRESS=":8080" 2 | ENABLE_HTTP_REDIRECT= 3 | VITE_API_PATH="/api" 4 | 5 | # /etc/letsencrypt/live//privkey.pem 6 | SSL_KEY= 7 | 8 | # /etc/letsencrypt/live//fullchain.pem 9 | SSL_CERT= 10 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | HTTP_ADDRESS=":8080" 2 | ENABLE_HTTP_REDIRECT= 3 | VITE_API_PATH="http://localhost:8080/api" 4 | 5 | # /etc/letsencrypt/live//privkey.pem 6 | SSL_KEY= 7 | 8 | # /etc/letsencrypt/live//fullchain.pem 9 | SSL_CERT= 10 | -------------------------------------------------------------------------------- /internal/webrtc/whep_test.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import "testing" 4 | 5 | func TestAudioOnly(t *testing.T) { 6 | session := &whepSession{ 7 | videoTrack: nil, 8 | timestamp: 50000, 9 | } 10 | 11 | session.sendVideoPacket(nil, "", 0, 0, 0, true) 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: / 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/web" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | broadcast-box 2 | 3 | # dependencies 4 | /web/node_modules 5 | /web/.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /web/coverage 10 | 11 | # production 12 | /web/build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # rider 26 | /.idea 27 | 28 | # media files 29 | *.ogg 30 | *.h264 31 | -------------------------------------------------------------------------------- /web/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react(), tailwindcss()], 7 | css: { 8 | postcss: './postcss.config.js', 9 | }, 10 | build: { 11 | outDir: 'build', 12 | }, 13 | server: { 14 | open: true, // Opens browser on dev server start 15 | }, 16 | envDir: '../', 17 | // For backwards compatibility 18 | envPrefix: ['REACT_', 'VITE_'], 19 | }); 20 | -------------------------------------------------------------------------------- /.github/workflows/node-lint.yaml: -------------------------------------------------------------------------------- 1 | name: Node Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'web/**' 8 | pull_request: 9 | paths: 10 | - 'web/**' 11 | 12 | defaults: 13 | run: 14 | working-directory: web 15 | 16 | jobs: 17 | lint: 18 | name: Lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | - uses: actions/setup-node@v6 23 | with: 24 | node-version: 'lts/*' 25 | cache: 'npm' 26 | cache-dependency-path: web/package-lock.json 27 | 28 | - run: npm ci 29 | # - run: npm test #TODO add tests 30 | -------------------------------------------------------------------------------- /web/src/components/playerHeader/PlayerHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface PlayerHeaderProps { 4 | children: React.ReactNode; 5 | headerType: "Error" | "Warning" | "Success" 6 | } 7 | 8 | const PlayerHeader = (props: PlayerHeaderProps) => { 9 | return ( 10 |

20 | {props.children} 21 |

22 | ) 23 | } 24 | 25 | export default PlayerHeader; -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Broadcast Box 6 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /internal/webrtc/keyframe_detector.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "github.com/pion/rtp" 5 | ) 6 | 7 | const ( 8 | naluTypeBitmask = 0x1F 9 | 10 | idrNALUType = 5 11 | spsNALUType = 7 12 | ppsNALUType = 8 13 | ) 14 | 15 | func isKeyframe(pkt *rtp.Packet, codec videoTrackCodec, depacketizer rtp.Depacketizer) bool { 16 | if codec == videoTrackCodecH264 { 17 | nalu, err := depacketizer.Unmarshal(pkt.Payload) 18 | if err != nil || len(nalu) < 6 { 19 | return false 20 | } 21 | 22 | firstNaluType := nalu[4] & naluTypeBitmask 23 | return firstNaluType == idrNALUType || firstNaluType == spsNALUType || firstNaluType == ppsNALUType 24 | } 25 | return true 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yaml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | 11 | pull_request: 12 | paths: 13 | - '**.go' 14 | - 'go.mod' 15 | - 'go.sum' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v6 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v6 28 | with: 29 | go-version-file: go.mod 30 | 31 | - name: Build 32 | run: go build -v ./... 33 | 34 | - name: Test 35 | uses: robherley/go-test-action@v0 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node AS web-build 2 | WORKDIR /broadcast-box/web 3 | COPY . /broadcast-box 4 | RUN npm install && npm run build 5 | 6 | FROM golang:alpine AS go-build 7 | WORKDIR /broadcast-box 8 | ENV GOPROXY=direct 9 | ENV GOSUMDB=off 10 | COPY . /broadcast-box 11 | RUN apk add git 12 | RUN go build 13 | 14 | FROM golang:alpine 15 | COPY --from=web-build /broadcast-box/web/build /broadcast-box/web/build 16 | COPY --from=go-build /broadcast-box/broadcast-box /broadcast-box/broadcast-box 17 | COPY --from=go-build /broadcast-box/.env.production /broadcast-box/.env.production 18 | 19 | ENV APP_ENV=production 20 | ENV NETWORK_TEST_ON_START=true 21 | 22 | WORKDIR /broadcast-box 23 | ENTRYPOINT ["/broadcast-box/broadcast-box"] 24 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import App from './App' 5 | import {BrowserRouter} from "react-router-dom"; 6 | import { CinemaModeProvider } from './providers/CinemaModeProvider'; 7 | import {StatusProvider} from "./providers/StatusProvider"; 8 | 9 | // @ts-ignore 10 | const root = ReactDOM.createRoot(document.getElementById('root')) 11 | const path = import.meta.env.PUBLIC_URL; 12 | 13 | root.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | -------------------------------------------------------------------------------- /examples/gstreamer-whep-to-rtmp.nu: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nu 2 | 3 | def main [whep_endpoint: string, whep_token: string, rtmp_location: string] { 4 | (gst-launch-1.0 5 | flvmux 6 | streamable=true 7 | name=flvmux 8 | ! rtmpsink 9 | $"location=($rtmp_location)" 10 | whepsrc 11 | name=whep 12 | $"auth-token=($whep_token)" 13 | $"whep-endpoint=($whep_endpoint)" 14 | video-caps="application/x-rtp,payload=127,encoding-name=H264,media=video,clock-rate=90000" 15 | audio-caps="application/x-rtp,payload=96,encoding-name=OPUS,media=audio,clock-rate=48000" 16 | ! rtpopusdepay 17 | ! fakesink 18 | whep. 19 | ! rtph264depay 20 | ! h264parse 21 | ! queue 22 | ! flvmux. 23 | ) 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Routes, Route } from 'react-router-dom' 3 | 4 | import BrowserBroadcaster from "./components/broadcast/Broadcast"; 5 | import PlayerPage from "./components/player/PlayerPage"; 6 | import RootWrapper from "./components/rootWrapper/RootWrapper"; 7 | import Frontpage from "./components/selection/Frontpage"; 8 | import Statistics from "./components/statistics/Statistics"; 9 | 10 | function App() { 11 | return ( 12 | 13 | }> 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | 19 | 20 | ) 21 | } 22 | 23 | export default App -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | broadcast-box: 5 | environment: 6 | - INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP=yes 7 | image: seaduboi/broadcast-box:latest 8 | hostname: broadcast-box 9 | container_name: broadcast-box 10 | network_mode: "host" 11 | privileged: true 12 | 13 | caddy: 14 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 15 | environment: 16 | - CADDY_INGRESS_NETWORKS=caddy 17 | volumes: 18 | - /var/run/docker.sock:/var/run/docker.sock 19 | - caddy_data:/data 20 | network_mode: "host" 21 | labels: 22 | caddy: ${URL} 23 | caddy.reverse_proxy: "localhost:8080" 24 | 25 | watchtower: 26 | restart: always 27 | image: containrrr/watchtower:latest 28 | volumes: 29 | - /var/run/docker.sock:/var/run/docker.sock 30 | 31 | volumes: 32 | caddy_data: {} 33 | -------------------------------------------------------------------------------- /.github/workflows/go-lint.yaml: -------------------------------------------------------------------------------- 1 | name: Go Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | 11 | pull_request: 12 | paths: 13 | - '**.go' 14 | - 'go.mod' 15 | - 'go.sum' 16 | 17 | permissions: 18 | contents: read 19 | 20 | # Avoid adding additional steps to this job. Create a new job instead. 21 | # https://github.com/golangci/golangci-lint-action/issues/244 22 | jobs: 23 | golangci: 24 | name: lint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v6 28 | - uses: actions/setup-go@v6 29 | with: 30 | go-version: '1.24' 31 | cache: false 32 | - name: golangci-lint 33 | uses: golangci/golangci-lint-action@v9 34 | with: 35 | version: 'latest' 36 | args: --timeout 5m 37 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @layer base { 4 | body { 5 | margin: 0; 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 7 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 14 | } 15 | } 16 | 17 | /* 18 | The default border color has changed to `currentColor` in Tailwind CSS v4, 19 | so we've added these compatibility styles to make sure everything still 20 | looks the same as it did with Tailwind CSS v3. 21 | 22 | If we ever want to remove these styles, we need to add an explicit border 23 | color utility to any element that depends on these defaults. 24 | */ 25 | @layer base { 26 | *, 27 | ::after, 28 | ::before, 29 | ::backdrop, 30 | ::file-selector-button { 31 | border-color: var(--color-gray-200, currentColor); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Glimesh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /examples/gstreamer-broadcast.nu: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nu 2 | 3 | def main [ whip_endpoint: string, auth_token: string, stream_type = "testsrc" ] { 4 | mut srcelem = [] 5 | mut audioelem = [] 6 | 7 | if $stream_type == "testsrc" { 8 | $srcelem = [ videotestsrc pattern=smpte-rp-219 ] 9 | $audioelem = [ audiotestsrc wave=8 ] 10 | } else { 11 | $srcelem = [ v4l2src "device=/dev/video1" ] 12 | $audioelem = [ pulsesrc "device=alsa_input.usb-MACROSILICON_USB3._0_capture-02.analog-stereo" ] 13 | } 14 | 15 | (gst-launch-1.0 -v 16 | $srcelem 17 | ! videoconvert 18 | ! x264enc tune="zerolatency" 19 | ! rtph264pay 20 | ! application/x-rtp,media=video,encoding-name=H264,payload=97,clock-rate=90000 21 | ! whip0.sink_0 22 | $audioelem 23 | ! audioconvert 24 | ! opusenc 25 | ! rtpopuspay 26 | ! application/x-rtp,media=audio,encoding-name=OPUS,payload=96,clock-rate=48000,encoding-params=(string)2 27 | ! whip0.sink_1 28 | whipsink 29 | name=whip0 30 | use-link-headers=true 31 | $"whip-endpoint=($whip_endpoint)" 32 | $"auth-token=($auth_token)" 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /web/src/components/player/components/CurrentViewersComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useState} from "react"; 2 | import {UsersIcon} from "@heroicons/react/20/solid"; 3 | import {StatusContext} from "../../../providers/StatusProvider"; 4 | 5 | interface CurrentViewersComponentProps { 6 | streamKey: string; 7 | } 8 | 9 | const CurrentViewersComponent = (props: CurrentViewersComponentProps) => { 10 | const { streamKey } = props; 11 | const { streamStatus, refreshStatus } = useContext(StatusContext); 12 | const [currentViewersCount, setCurrentViewersCount] = useState(0) 13 | 14 | useEffect(() => { 15 | refreshStatus() 16 | }, []); 17 | 18 | useEffect(() => { 19 | if(!streamKey || !streamStatus){ 20 | return; 21 | } 22 | 23 | const sessions = streamStatus.filter((session) => session.streamKey === streamKey); 24 | 25 | if(sessions.length !== 0){ 26 | setCurrentViewersCount(() => 27 | sessions.length !== 0 28 | ? sessions[0].whepSessions.length 29 | : 0) 30 | } 31 | }, [streamStatus]); 32 | 33 | return ( 34 |
35 | 36 | {currentViewersCount} 37 |
38 | ) 39 | } 40 | 41 | export default CurrentViewersComponent -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "broadcast-box", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@heroicons/react": "^2.2.0", 8 | "@web3-storage/parse-link-header": "^3.1.0", 9 | "react": "^19.1.1", 10 | "react-dom": "^19.2.0" 11 | }, 12 | "scripts": { 13 | "start": "vite", 14 | "host": "vite --host", 15 | "build": "vite build", 16 | "lint": "eslint ./src --max-warnings 0" 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "react-app", 21 | "react-app/jest" 22 | ] 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 38 | "@tailwindcss/postcss": "^4.1.17", 39 | "@tailwindcss/vite": "^4.1.17", 40 | "@types/react": "^19.2.7", 41 | "@types/react-dom": "^19.2.3", 42 | "@vitejs/plugin-react": "^5.1.1", 43 | "postcss": "^8.5.6", 44 | "react-router-dom": "^7.9.6", 45 | "tailwindcss": "^4.1.17", 46 | "typescript": "^5.9.3", 47 | "vite": "^7.2.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/providers/CinemaModeProvider.tsx: -------------------------------------------------------------------------------- 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>; 7 | toggleCinemaMode: () => void; 8 | } 9 | 10 | export const CinemaModeContext = React.createContext({ 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(() => ({ 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 | 34 | {props.children} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 5 | 6 | /* Modules */ 7 | "module": "Es2022", /* Specify what module code is generated. */ 8 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 9 | "types": ["vite/client"], /* Specify type package names to be included without being referenced in a source file. */ 10 | 11 | /* Interop Constraints */ 12 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 13 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 14 | 15 | /* Type Checking */ 16 | "strict": true, /* Enable all strict type-checking options. */ 17 | 18 | /* Completeness */ 19 | "skipLibCheck": true, 20 | 21 | /* Skip type checking all .d.ts files. */ 22 | "jsx": "react", 23 | 24 | "outDir": "./build", 25 | "allowJs": false, 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "**/*.js" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/glimesh/broadcast-box 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/google/uuid v1.6.0 9 | github.com/joho/godotenv v1.5.1 10 | github.com/pion/dtls/v3 v3.0.7 11 | github.com/pion/ice/v3 v3.0.16 12 | github.com/pion/interceptor v0.1.42 13 | github.com/pion/rtcp v1.2.16 14 | github.com/pion/rtp v1.8.25 15 | github.com/pion/sdp/v3 v3.0.16 16 | github.com/pion/webrtc/v4 v4.1.6 17 | github.com/stretchr/testify v1.11.1 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/pion/datachannel v1.5.10 // indirect 23 | github.com/pion/dtls/v2 v2.2.12 // indirect 24 | github.com/pion/ice/v4 v4.0.10 // indirect 25 | github.com/pion/logging v0.2.4 // indirect 26 | github.com/pion/mdns/v2 v2.0.7 // indirect 27 | github.com/pion/randutil v0.1.0 // indirect 28 | github.com/pion/sctp v1.8.40 // indirect 29 | github.com/pion/srtp/v3 v3.0.8 // indirect 30 | github.com/pion/stun/v2 v2.0.0 // indirect 31 | github.com/pion/stun/v3 v3.0.0 // indirect 32 | github.com/pion/transport/v2 v2.2.10 // indirect 33 | github.com/pion/transport/v3 v3.1.1 // indirect 34 | github.com/pion/turn/v3 v3.0.3 // indirect 35 | github.com/pion/turn/v4 v4.1.1 // indirect 36 | github.com/pmezard/go-difflib v1.0.0 // indirect 37 | github.com/wlynxg/anet v0.0.5 // indirect 38 | golang.org/x/crypto v0.39.0 // indirect 39 | golang.org/x/net v0.41.0 // indirect 40 | golang.org/x/sys v0.33.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Broadcast Box 2 | 3 | Contributing to broadcast box is greatly appreciated. We are happy to give guidance, answer questions and review PRs. 4 | 5 | ## Getting Started 6 | 7 | Broadcast Box is made up of two parts. The server is written in Go and is in charge 8 | of ingesting and broadcasting WebRTC. The frontend is in react and connects to the Go 9 | backend. 10 | 11 | ### Configuring 12 | 13 | Configurations can be made in [.env.development](./.env.development). 14 | 15 | ### Frontend 16 | 17 | React dependencies are installed by running `npm install` in the `web` directory, then run `npm start` to serve the frontend. You should see the following: 18 | 19 | ```console 20 | Compiled successfully! 21 | 22 | You can now view broadcast-box in the browser. 23 | 24 | Local: http://localhost:3000 25 | On Your Network: http://192.168.1.57:3000 26 | 27 | Note that the development build is not optimized. 28 | To create a production build, use npm run build. 29 | 30 | webpack compiled successfully 31 | ``` 32 | 33 | ### Backend 34 | 35 | Go dependencies are automatically installed. 36 | 37 | To run the Go server run `APP_ENV=development go run .` in the root of this project. You will see the logs 38 | like the following. 39 | 40 | ```console 41 | 2022/12/11 15:22:47 Loading `.env.development` 42 | 2022/12/11 15:22:47 Running HTTP Server at `:8080` 43 | ``` 44 | 45 | To use Broadcast Box navigate to: `http://localhost:3000`. In your broadcast tool of choice, you will broadcast to `http://localhost:8080/api/whip`. 46 | -------------------------------------------------------------------------------- /examples/webhook-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | type webhookPayload struct { 10 | Action string `json:"action"` 11 | IP string `json:"ip"` 12 | BearerToken string `json:"bearerToken"` 13 | QueryParams map[string]string `json:"queryParams"` 14 | UserAgent string `json:"userAgent"` 15 | } 16 | 17 | type webhookResponse struct { 18 | StreamKey string `json:"streamKey"` 19 | } 20 | 21 | func main() { 22 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 23 | if r.Method != "POST" { 24 | http.Error(w, "Only POST method is accepted", http.StatusMethodNotAllowed) 25 | return 26 | } 27 | 28 | var payload webhookPayload 29 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 30 | http.Error(w, "Invalid JSON", http.StatusBadRequest) 31 | return 32 | } 33 | 34 | if payload.BearerToken == "broadcastBoxRulez" { 35 | w.WriteHeader(http.StatusOK) 36 | if err := json.NewEncoder(w).Encode(webhookResponse{StreamKey: payload.BearerToken}); err != nil { 37 | http.Error(w, err.Error(), http.StatusInternalServerError) 38 | } 39 | } else { 40 | w.WriteHeader(http.StatusForbidden) 41 | if err := json.NewEncoder(w).Encode(webhookResponse{}); err != nil { 42 | http.Error(w, err.Error(), http.StatusInternalServerError) 43 | } 44 | } 45 | }) 46 | 47 | log.Println("Server listening on port 8081") 48 | if err := http.ListenAndServe("127.0.0.1:8081", nil); err != nil { 49 | log.Fatalf("Could not start server: %s\n", err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /web/src/components/rootWrapper/RootWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { Link, Outlet } from 'react-router-dom' 3 | import React from 'react'; 4 | import {CinemaModeContext} from "../../providers/CinemaModeProvider"; 5 | 6 | const RootWrapper = () => { 7 | const { cinemaMode } = useContext(CinemaModeContext); 8 | const navbarEnabled = !cinemaMode; 9 | 10 | return ( 11 |
12 | {navbarEnabled && ( 13 | 22 | )} 23 | 24 |
25 | 26 |
27 | 28 | 41 | 42 |
43 | ) 44 | } 45 | 46 | export default RootWrapper -------------------------------------------------------------------------------- /.github/workflows/build-container.yaml: -------------------------------------------------------------------------------- 1 | name: Build Container 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - 'README.md' 8 | - 'CONTRIBUTING.md' 9 | - 'LICENSE' 10 | - '.github/**' 11 | tags: 12 | - v* 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v6 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v3 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: 'Login to GitHub Container Registry' 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Extract metadata (tags, labels) for Docker 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: | 45 | seaduboi/broadcast-box 46 | ghcr.io/${{ github.repository }} 47 | tags: | 48 | type=raw,value=latest 49 | type=ref,event=tag 50 | 51 | - name: Build and push 52 | uses: docker/build-push-action@v6 53 | with: 54 | context: . 55 | platforms: linux/amd64, linux/arm64 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | -------------------------------------------------------------------------------- /web/src/components/player/components/VolumeComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from "react"; 2 | import {SpeakerWaveIcon, SpeakerXMarkIcon} from "@heroicons/react/16/solid"; 3 | 4 | interface VolumeComponentProps { 5 | isMuted: boolean; 6 | onStateChanged: (isMuted: boolean) => void; 7 | onVolumeChanged: (value: number) => void; 8 | } 9 | 10 | const VolumeComponent = (props: VolumeComponentProps) => { 11 | const [isMuted, setIsMuted] = useState(props.isMuted); 12 | const [showSlider, setShowSlider] = useState(false); 13 | const volumeRef = useRef(20); 14 | 15 | useEffect(() => { 16 | props.onStateChanged(isMuted); 17 | }, [isMuted]); 18 | 19 | const onVolumeChange = (newValue: number) => { 20 | if(isMuted && newValue !== 0){ 21 | setIsMuted((_) => false) 22 | } 23 | if(!isMuted && newValue === 0){ 24 | setIsMuted((_) => true) 25 | } 26 | 27 | props.onVolumeChanged(newValue / 100); 28 | } 29 | 30 | return
setShowSlider(true)} 32 | onMouseLeave={() => setShowSlider(false)} 33 | className="flex justify-start max-w-42 gap-2 items-center" 34 | > 35 | {isMuted && ( 36 | setIsMuted((prev) => !prev)}/> 37 | )} 38 | {!isMuted && ( 39 | setIsMuted((prev) => !prev)}/> 40 | )} 41 | onVolumeChange(parseInt(event.target.value))} 47 | className={ 48 | ` 49 | ${!showSlider && ` 50 | invisible 51 | `} 52 | w-18 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700`}/> 53 |
54 | } 55 | export default VolumeComponent 56 | -------------------------------------------------------------------------------- /web/src/components/player/components/PlayPauseComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {PauseIcon, PlayIcon} from "@heroicons/react/16/solid"; 3 | 4 | interface PlayPauseComponentProps { 5 | videoRef: React.RefObject; 6 | } 7 | 8 | const PlayPauseComponent = (props: PlayPauseComponentProps) => { 9 | const [isPaused, setIsPaused] = useState(true); 10 | 11 | if (props.videoRef.current === null) { 12 | return <>; 13 | } 14 | 15 | useEffect(() => { 16 | if (props.videoRef.current === null) { 17 | return; 18 | } 19 | 20 | const canPlayHandler = (_: Event) => props.videoRef.current?.play() 21 | const playingHandler = (_: Event) => setIsPaused(() => false) 22 | const pauseHandler = (_: Event) => setIsPaused(() => true); 23 | 24 | props.videoRef.current.addEventListener("canplay", canPlayHandler) 25 | props.videoRef.current.addEventListener("playing", playingHandler) 26 | props.videoRef.current.addEventListener("pause", pauseHandler) 27 | 28 | return () => { 29 | if (props.videoRef.current) { 30 | props.videoRef.current.removeEventListener("canplay", canPlayHandler); 31 | props.videoRef.current.removeEventListener("playing", playingHandler); 32 | props.videoRef.current.removeEventListener("pause", pauseHandler); 33 | } 34 | } 35 | }, []); 36 | 37 | useEffect(() => { 38 | if(isPaused){ 39 | props.videoRef.current?.pause(); 40 | } 41 | if(!isPaused){ 42 | props.videoRef.current?.play().catch((err) => console.error("VideoError", err)); 43 | } 44 | }, [isPaused]); 45 | 46 | if (isPaused) { 47 | return props.videoRef.current?.play()}/> 48 | } 49 | if (!isPaused) { 50 | return props.videoRef.current?.pause()}/> 51 | } 52 | } 53 | 54 | export default PlayPauseComponent -------------------------------------------------------------------------------- /internal/webhook/webhook_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestCallWebhook(t *testing.T) { 13 | // Setup a Mock HTTP Server 14 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/ok": 17 | w.WriteHeader(http.StatusOK) 18 | _ = json.NewEncoder(w).Encode(webhookResponse{StreamKey: "dummy_stream_key"}) 19 | case "/timeout": 20 | time.Sleep(7 * time.Second) 21 | case "/error": 22 | w.WriteHeader(http.StatusInternalServerError) 23 | case "/badjson": 24 | w.WriteHeader(http.StatusOK) 25 | _, _ = w.Write([]byte("not a json")) 26 | default: 27 | w.WriteHeader(http.StatusNotFound) 28 | } 29 | })) 30 | defer mockServer.Close() 31 | 32 | tests := []struct { 33 | name string 34 | url string 35 | expectedErr bool 36 | expectedKey string 37 | }{ 38 | {"Success Case", "/ok", false, "dummy_stream_key"}, 39 | {"Server Timeout", "/timeout", true, ""}, 40 | {"Server Error", "/error", true, ""}, 41 | {"Malformed JSON", "/badjson", true, ""}, 42 | {"Not Found", "/notfound", true, ""}, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | req, _ := http.NewRequest("GET", "/", nil) 48 | req.RemoteAddr = "127.0.0.1" 49 | req.Header.Set("User-Agent", "test-agent") 50 | 51 | // call the function with test layers 52 | result, err := CallWebhook(fmt.Sprintf("%s%s", mockServer.URL, tt.url), "action", "bearerToken", req) 53 | 54 | if tt.expectedErr && err == nil { 55 | t.Fatalf("expected an error but got none") 56 | } 57 | 58 | if !tt.expectedErr && err != nil { 59 | t.Fatalf("did not expect an error but got %v", err) 60 | } 61 | 62 | if result != tt.expectedKey { 63 | t.Fatalf("expected stream key %s but got %s", tt.expectedKey, result) 64 | } 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /web/src/components/player/components/QualitySelectorComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, {ChangeEvent, useState} from "react"; 2 | import {ChartBarIcon} from "@heroicons/react/16/solid"; 3 | 4 | interface QualityComponentProps { 5 | layers: string[]; 6 | layerEndpoint: string; 7 | hasPacketLoss: boolean; 8 | } 9 | 10 | const QualitySelectorComponent = (props: QualityComponentProps) => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const [currentLayer, setCurrentLayer] = useState(''); 13 | 14 | const onLayerChange = (event: ChangeEvent) => { 15 | fetch(props.layerEndpoint, { 16 | method: 'POST', 17 | body: JSON.stringify({mediaId: '1', encodingId: event.target.value}), 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | } 21 | }).catch((err) => console.error("onLayerChange", err)) 22 | setIsOpen(false) 23 | setCurrentLayer(event.target.value) 24 | } 25 | 26 | let layerList = [ 27 | currentLayer, 28 | ...props.layers.filter(layer => layer !== currentLayer) 29 | ].map(layer => ) 30 | if (layerList[0].props.value === '') { 31 | layerList[0] = 32 | } 33 | 34 | return ( 35 |
36 | setIsOpen((prev) => props.layers.length <= 1 ? false : !prev)}/> 39 | 40 | {isOpen && ( 41 | 42 | 67 | )} 68 |
69 | ) 70 | } 71 | 72 | export default QualitySelectorComponent -------------------------------------------------------------------------------- /internal/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | const defaultTimeout = time.Second * 5 12 | 13 | type webhookPayload struct { 14 | Action string `json:"action"` 15 | IP string `json:"ip"` 16 | BearerToken string `json:"bearerToken"` 17 | QueryParams map[string]string `json:"queryParams"` 18 | UserAgent string `json:"userAgent"` 19 | } 20 | 21 | type webhookResponse struct { 22 | StreamKey string `json:"streamKey"` 23 | } 24 | 25 | func CallWebhook(url, action, bearerToken string, r *http.Request) (string, error) { 26 | start := time.Now() 27 | 28 | queryParams := make(map[string]string) 29 | for k, v := range r.URL.Query() { 30 | if len(v) > 0 { 31 | queryParams[k] = v[0] 32 | } 33 | } 34 | 35 | jsonPayload, err := json.Marshal(webhookPayload{ 36 | Action: action, 37 | IP: getIPAddress(r), 38 | BearerToken: bearerToken, 39 | QueryParams: queryParams, 40 | UserAgent: r.UserAgent(), 41 | }) 42 | if err != nil { 43 | return "", fmt.Errorf("failed to marshal payload: %w", err) 44 | } 45 | 46 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) 47 | if err != nil { 48 | return "", fmt.Errorf("failed to create request: %w", err) 49 | } 50 | req.Header.Set("Content-Type", "application/json") 51 | 52 | resp, err := (&http.Client{ 53 | Timeout: defaultTimeout, 54 | }).Do(req) 55 | if err != nil { 56 | return "", fmt.Errorf("webhook request failed after %v: %w", time.Since(start), err) 57 | } 58 | defer resp.Body.Close() //nolint 59 | 60 | if resp.StatusCode != http.StatusOK { 61 | return "", fmt.Errorf("webhook returned non-200 Status: %v", resp.StatusCode) 62 | } 63 | 64 | response := webhookResponse{} 65 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 66 | return "", fmt.Errorf("failed to decode response: %w", err) 67 | } 68 | 69 | return response.StreamKey, nil 70 | } 71 | 72 | func getIPAddress(r *http.Request) string { 73 | if r.Header.Get("X-Forwarded-For") != "" { 74 | return r.Header.Get("X-Forwarded-For") 75 | } 76 | return r.RemoteAddr 77 | } 78 | -------------------------------------------------------------------------------- /web/src/components/selection/AvailableStreams.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useState} from "react"; 2 | import {useNavigate} from "react-router-dom"; 3 | import {StatusContext} from "../../providers/StatusProvider"; 4 | 5 | interface StatusResult { 6 | streamKey: string; 7 | videoStreams: VideoStream[]; 8 | } 9 | 10 | interface VideoStream { 11 | lastKeyFrameSeen: string; 12 | } 13 | 14 | interface StreamEntry { 15 | streamKey: string; 16 | } 17 | 18 | const AvailableStreams = () => { 19 | const navigate = useNavigate(); 20 | 21 | const {streamStatus, refreshStatus} = useContext(StatusContext) 22 | const [streams, setStreams] = useState(undefined); 23 | 24 | useEffect(() => { 25 | refreshStatus() 26 | }, []); 27 | 28 | useEffect(() => { 29 | setStreams(() => 30 | streamStatus?.filter((resultEntry) => resultEntry.videoStreams.length > 0) 31 | .map((resultEntry: StatusResult) => ({ 32 | streamKey: resultEntry.streamKey, 33 | videoStreams: resultEntry.videoStreams 34 | }))); 35 | }, [streamStatus]) 36 | 37 | const onWatchStreamClick = (key: string) => { 38 | if (key !== '') { 39 | navigate(`/${key}`); 40 | } 41 | } 42 | 43 | if (streams === undefined) { 44 | return <>; 45 | } 46 | 47 | return ( 48 |
49 |

Current Streams

50 | {streams.length === 0 &&

No streams currently available

} 51 | {streams.length !== 0 &&

Click a stream to join it

} 52 | 53 |
54 | 55 |
56 | {streams.map((e, i) => ( 57 | 63 | )) 64 | } 65 |
66 |
67 | ) 68 | } 69 | 70 | export default AvailableStreams -------------------------------------------------------------------------------- /web/src/components/shared/ModalTextInput.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef} from "react"; 2 | import {useState} from "react"; 3 | 4 | interface Props { 5 | title: string; 6 | message: string; 7 | children?: React.ReactNode; 8 | isOpen: boolean; 9 | onClose?: () => void; 10 | onAccept?: (result: T) => void; 11 | onChange?: (result: T) => void; 12 | initialValue?: T; 13 | 14 | canCloseOnBackgroundClick?: boolean; 15 | } 16 | 17 | export default function ModalTextInput(props: Props) { 18 | const [isOpen, setIsOpen] = useState(props.isOpen); 19 | const valueRef = useRef(null); 20 | 21 | if(!isOpen){ 22 | return <> 23 | } 24 | 25 | return ( 26 |
27 |
props.canCloseOnBackgroundClick && setIsOpen(false)} 30 | > 31 |
e.stopPropagation()} 34 | > 35 |

{props.title}

36 |

{props.message}

37 | 38 | 44 | 45 | {/*Buttons*/} 46 |
47 | {props.onAccept !== null && ( 48 | 54 | )} 55 | 61 | 62 |
63 |
64 |
65 |
66 | ); 67 | } -------------------------------------------------------------------------------- /internal/webrtc/track_multi_codec.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "github.com/pion/rtp" 5 | "github.com/pion/webrtc/v4" 6 | ) 7 | 8 | type trackMultiCodec struct { 9 | ssrc webrtc.SSRC 10 | writeStream webrtc.TrackLocalWriter 11 | 12 | payloadTypeH264, payloadTypeH265, payloadTypeVP8, payloadTypeVP9, payloadTypeAV1 uint8 13 | 14 | id, rid, streamID string 15 | } 16 | 17 | func (t *trackMultiCodec) Bind(ctx webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) { 18 | t.ssrc = ctx.SSRC() 19 | t.writeStream = ctx.WriteStream() 20 | 21 | codecs := ctx.CodecParameters() 22 | for i := range codecs { 23 | switch getVideoTrackCodec(codecs[i].MimeType) { 24 | case videoTrackCodecH264: 25 | t.payloadTypeH264 = uint8(codecs[i].PayloadType) 26 | case videoTrackCodecVP8: 27 | t.payloadTypeVP8 = uint8(codecs[i].PayloadType) 28 | case videoTrackCodecVP9: 29 | t.payloadTypeVP9 = uint8(codecs[i].PayloadType) 30 | case videoTrackCodecAV1: 31 | t.payloadTypeAV1 = uint8(codecs[i].PayloadType) 32 | case videoTrackCodecH265: 33 | t.payloadTypeH265 = uint8(codecs[i].PayloadType) 34 | } 35 | } 36 | 37 | return webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264, RTCPFeedback: videoRTCPFeedback}}, nil 38 | } 39 | 40 | func (t *trackMultiCodec) Unbind(webrtc.TrackLocalContext) error { 41 | return nil 42 | } 43 | 44 | func (t *trackMultiCodec) WriteRTP(p *rtp.Packet, codec videoTrackCodec) error { 45 | p.SSRC = uint32(t.ssrc) 46 | 47 | switch codec { 48 | case videoTrackCodecH264: 49 | p.PayloadType = t.payloadTypeH264 50 | case videoTrackCodecVP8: 51 | p.PayloadType = t.payloadTypeVP8 52 | case videoTrackCodecVP9: 53 | p.PayloadType = t.payloadTypeVP9 54 | case videoTrackCodecAV1: 55 | p.PayloadType = t.payloadTypeAV1 56 | case videoTrackCodecH265: 57 | p.PayloadType = t.payloadTypeH265 58 | } 59 | 60 | _, err := t.writeStream.WriteRTP(&p.Header, p.Payload) 61 | return err 62 | } 63 | 64 | func (t *trackMultiCodec) ID() string { return t.id } 65 | func (t *trackMultiCodec) RID() string { return t.rid } 66 | func (t *trackMultiCodec) StreamID() string { return t.streamID } 67 | func (t *trackMultiCodec) Kind() webrtc.RTPCodecType { 68 | return webrtc.RTPCodecTypeVideo 69 | } 70 | -------------------------------------------------------------------------------- /examples/simple-watcher.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | simple-watcher 9 | 10 | 11 | 12 | WHEP URL
13 | Stream Key
14 | 15 | 16 |

Video

17 | 18 | 19 |

Connection State

20 |

21 | 22 | 23 | 67 | 68 | -------------------------------------------------------------------------------- /web/src/components/player/PlayerPage.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useState} from "react"; 2 | import Player from "./Player"; 3 | import {useNavigate} from "react-router-dom"; 4 | import {CinemaModeContext} from "../../providers/CinemaModeProvider"; 5 | import ModalTextInput from "../shared/ModalTextInput"; 6 | 7 | const PlayerPage = () => { 8 | const navigate = useNavigate(); 9 | const {cinemaMode, toggleCinemaMode} = useContext(CinemaModeContext); 10 | const [streamKeys, setStreamKeys] = useState([window.location.pathname.substring(1)]); 11 | const [isModalOpen, setIsModelOpen] = useState(false); 12 | 13 | const addStream = (streamKey: string) => { 14 | if (streamKeys.some((key: string) => key.toLowerCase() === streamKey.toLowerCase())) { 15 | return; 16 | } 17 | setStreamKeys((prev) => [...prev, streamKey]); 18 | setIsModelOpen((prev) => !prev); 19 | }; 20 | 21 | return ( 22 |
23 | {isModalOpen && ( 24 | 25 | title="Add stream" 26 | message={"Insert stream key to add to multi stream"} 27 | isOpen={isModalOpen} 28 | canCloseOnBackgroundClick={false} 29 | onClose={() => setIsModelOpen(false)} 30 | onAccept={(result: string) => addStream(result)} 31 | /> 32 | )} 33 | 34 |
35 |
36 | {streamKeys.map((streamKey) => 37 | navigate('/') 44 | : () => setStreamKeys((prev) => prev.filter((key) => key !== streamKey)) 45 | } 46 | /> 47 | )} 48 |
49 | 50 | {/*Implement footer menu*/} 51 |
52 | 58 | 59 | {/*Show modal to add stream keys with*/} 60 | 65 |
66 |
67 |
68 | ) 69 | }; 70 | 71 | export default PlayerPage; -------------------------------------------------------------------------------- /examples/dynamic-watcher.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | dynamic-watcher 9 | 10 | 11 | 12 | WHEP URL
13 | 14 | 15 |

Videos

16 |
17 | 18 | 19 | 93 | 94 | -------------------------------------------------------------------------------- /internal/webrtc/whip_test.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/pion/webrtc/v4" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | const testStreamKey = "test" 13 | 14 | func doesWHIPSessionExist() (ok bool) { 15 | streamMapLock.Lock() 16 | defer streamMapLock.Unlock() 17 | 18 | _, ok = streamMap[testStreamKey] 19 | return 20 | } 21 | 22 | // Asserts that a old PeerConnection doesn't destroy the new one 23 | // when it disconnects 24 | func TestReconnect(t *testing.T) { 25 | Configure() 26 | localTrack, err := webrtc.NewTrackLocalStaticSample( 27 | webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "pion", 28 | ) 29 | require.NoError(t, err) 30 | 31 | // Create the first WHIP Session 32 | firstPublisherConnected, firstPublisherConnectedDone := context.WithCancel(context.TODO()) 33 | 34 | firstPublisher, err := webrtc.NewPeerConnection(webrtc.Configuration{}) 35 | require.NoError(t, err) 36 | 37 | firstPublisher.OnConnectionStateChange(func(c webrtc.PeerConnectionState) { 38 | if c == webrtc.PeerConnectionStateConnected { 39 | firstPublisherConnectedDone() 40 | 41 | } 42 | }) 43 | 44 | _, err = firstPublisher.AddTrack(localTrack) 45 | require.NoError(t, err) 46 | 47 | offer, err := firstPublisher.CreateOffer(nil) 48 | require.NoError(t, err) 49 | require.NoError(t, firstPublisher.SetLocalDescription(offer)) 50 | 51 | answer, err := WHIP(offer.SDP, testStreamKey) 52 | require.NoError(t, err) 53 | 54 | require.NoError(t, firstPublisher.SetRemoteDescription(webrtc.SessionDescription{ 55 | Type: webrtc.SDPTypeAnswer, 56 | SDP: answer, 57 | })) 58 | 59 | require.True(t, doesWHIPSessionExist()) 60 | <-firstPublisherConnected.Done() 61 | 62 | // Create the second WHIP Session 63 | secondPublisherConnected, secondPublisherConnectedDone := context.WithCancel(context.TODO()) 64 | 65 | secondPublisher, err := webrtc.NewPeerConnection(webrtc.Configuration{}) 66 | require.NoError(t, err) 67 | 68 | secondPublisher.OnConnectionStateChange(func(c webrtc.PeerConnectionState) { 69 | if c == webrtc.PeerConnectionStateConnected { 70 | secondPublisherConnectedDone() 71 | 72 | } 73 | }) 74 | 75 | _, err = secondPublisher.AddTrack(localTrack) 76 | require.NoError(t, err) 77 | 78 | offer, err = secondPublisher.CreateOffer(nil) 79 | require.NoError(t, err) 80 | require.NoError(t, secondPublisher.SetLocalDescription(offer)) 81 | 82 | answer, err = WHIP(offer.SDP, testStreamKey) 83 | require.NoError(t, err) 84 | 85 | require.NoError(t, secondPublisher.SetRemoteDescription(webrtc.SessionDescription{ 86 | Type: webrtc.SDPTypeAnswer, 87 | SDP: answer, 88 | })) 89 | 90 | require.True(t, doesWHIPSessionExist()) 91 | <-secondPublisherConnected.Done() 92 | 93 | // Close the first WHIP Session, the session must still exist 94 | require.NoError(t, firstPublisher.Close()) 95 | time.Sleep(time.Second) 96 | require.True(t, doesWHIPSessionExist()) 97 | 98 | // Close the second WHIP Session, the session must be gone 99 | require.NoError(t, secondPublisher.Close()) 100 | time.Sleep(time.Second) 101 | require.False(t, doesWHIPSessionExist()) 102 | } 103 | -------------------------------------------------------------------------------- /web/src/components/statistics/Statistics.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect} from "react"; 2 | import {StatusContext} from "../../providers/StatusProvider"; 3 | import {useNavigate} from "react-router-dom"; 4 | 5 | const Statistics = () => { 6 | const {streamStatus, refreshStatus} = useContext(StatusContext); 7 | const navigate = useNavigate(); 8 | 9 | useEffect(() => { 10 | refreshStatus(); 11 | }, []); 12 | 13 | return ( 14 |
15 |

📊 Statistics

16 | 17 | {!streamStatus || streamStatus?.length === 0 && ( 18 |

No statistics currently available

19 | )} 20 | 21 |
22 | {streamStatus?.map((status, i) => ( 23 |
24 |
25 |
27 | Stream Key: {status.streamKey} 28 |
29 | 34 |
35 | 36 | {/* VideoStreams */} 37 |
38 |

🎥 Video Streams

39 |
40 | {status.videoStreams.map((stream, index) => ( 41 |
45 |
RID: {stream.rid}
46 |
Packets Received: {stream.packetsReceived}
47 |
Last Key Frame: {stream.lastKeyFrameSeen}
48 |
49 | ))} 50 |
51 |
52 | 53 | {/* WhepStreams */} 54 |
55 |

🧬 WHEP Sessions

56 |
57 | {status.whepSessions.map((session, index) => ( 58 |
62 |
ID: {session.id}
63 |
Layer: {session.currentLayer}
64 |
Timestamp: {session.timestamp}
65 |
Packets Written: {session.packetsWritten}
66 |
Seq Num: {session.sequenceNumber}
67 |
68 | ))} 69 |
70 |
71 |
72 | ))} 73 |
74 |
75 | ); 76 | }; 77 | 78 | export default Statistics; 79 | -------------------------------------------------------------------------------- /web/src/providers/StatusProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useMemo, useRef, useState} from "react"; 2 | 3 | interface WhepSession { 4 | id: string; 5 | currentLayer: string; 6 | sequenceNumber: number; 7 | timestamp: number; 8 | packetsWritten: number; 9 | } 10 | 11 | interface StatusResult { 12 | streamKey: string; 13 | whepSessions: WhepSession[]; 14 | videoStreams: VideoStream[]; 15 | } 16 | 17 | interface VideoStream { 18 | rid: string; 19 | packetsReceived: number; 20 | lastKeyFrameSeen: string; 21 | } 22 | 23 | interface StatusProviderProps { 24 | children: React.ReactNode; 25 | } 26 | 27 | class FetchError extends Error { 28 | status: number; 29 | 30 | constructor(message: string, status: number) { 31 | super(message); 32 | this.status = status; 33 | } 34 | } 35 | 36 | const apiPath = import.meta.env.VITE_API_PATH; 37 | const fetchStatus = ( 38 | onSuccess?: (statusResults: StatusResult[]) => void, 39 | onError?: (error: FetchError) => void 40 | ) => 41 | fetch(`${apiPath}/status`, { 42 | method: 'GET', 43 | headers: { 44 | 'Content-Type': 'application/json' 45 | } 46 | }).then(result => { 47 | if (result.status === 503) { 48 | throw new FetchError('Status API disabled', result.status); 49 | } 50 | if (!result.ok) { 51 | throw new FetchError('Unknown error when calling status', result.status); 52 | } 53 | 54 | return result.json() 55 | }) 56 | .then((result: StatusResult[]) => onSuccess?.(result)) 57 | .catch((err: FetchError) => onError?.(err)); 58 | 59 | interface StatusProviderContextProps { 60 | streamStatus: StatusResult[] | undefined 61 | refreshStatus: () => void 62 | } 63 | 64 | export const StatusContext = React.createContext({ 65 | streamStatus: undefined, 66 | refreshStatus: () => { } 67 | }); 68 | 69 | export function StatusProvider(props: StatusProviderProps) { 70 | const [isStatusActive, setIsStatusActive] = useState(false) 71 | const [streamStatus, setStreamStatus] = useState(undefined) 72 | const intervalCountRef = useRef(5000); 73 | 74 | const fetchStatusResultHandler = (result: StatusResult[]) => { 75 | setStreamStatus(_ => result); 76 | } 77 | const fetchStatusErrorHandler = (error: FetchError) => { 78 | console.error("StatusProviderError", error.status, error.message) 79 | 80 | if (error.status === 503) { 81 | setIsStatusActive(() => false) 82 | setStreamStatus(() => undefined); 83 | } 84 | } 85 | 86 | useEffect(() => { 87 | fetchStatus( 88 | (result) => { 89 | setStreamStatus(_ => result) 90 | setIsStatusActive(_ => true) 91 | }, 92 | (error) => { 93 | if (error.status === 503) { 94 | setIsStatusActive(() => false) 95 | setStreamStatus(() => undefined); 96 | } 97 | 98 | console.error("StatusProviderError", error.status, error.message) 99 | }) 100 | .catch((err) => console.error("StatusProviderError", err)) 101 | }, []); 102 | 103 | useEffect(() => { 104 | if (!isStatusActive) { 105 | return 106 | } 107 | 108 | const intervalHandler = async () => { 109 | await fetchStatus( 110 | fetchStatusResultHandler, 111 | fetchStatusErrorHandler) 112 | } 113 | 114 | const interval = setInterval(intervalHandler, intervalCountRef.current) 115 | return () => clearInterval(interval) 116 | }, [isStatusActive]); 117 | 118 | const state = useMemo(() => ({ 119 | streamStatus: streamStatus, 120 | refreshStatus: async () => { 121 | await fetchStatus( 122 | fetchStatusResultHandler, 123 | fetchStatusErrorHandler) 124 | } 125 | }), [streamStatus]); 126 | 127 | return ( 128 | 129 | {props.children} 130 | 131 | ); 132 | } -------------------------------------------------------------------------------- /internal/networktest/networktest.go: -------------------------------------------------------------------------------- 1 | package networktest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | "time" 13 | 14 | "github.com/pion/ice/v3" 15 | "github.com/pion/sdp/v3" 16 | "github.com/pion/webrtc/v4" 17 | 18 | internalwebrtc "github.com/glimesh/broadcast-box/internal/webrtc" 19 | ) 20 | 21 | func Run(whepHandler func(res http.ResponseWriter, req *http.Request)) error { 22 | m := &webrtc.MediaEngine{} 23 | if err := internalwebrtc.PopulateMediaEngine(m); err != nil { 24 | return err 25 | } 26 | 27 | s := webrtc.SettingEngine{} 28 | s.SetNetworkTypes([]webrtc.NetworkType{ 29 | webrtc.NetworkTypeUDP4, 30 | webrtc.NetworkTypeUDP6, 31 | webrtc.NetworkTypeTCP4, 32 | webrtc.NetworkTypeTCP6, 33 | }) 34 | 35 | peerConnection, err := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(s)).NewPeerConnection(webrtc.Configuration{}) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio); err != nil { 41 | return err 42 | } 43 | 44 | if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil { 45 | return err 46 | } 47 | 48 | offer, err := peerConnection.CreateOffer(nil) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if err = peerConnection.SetLocalDescription(offer); err != nil { 54 | return err 55 | } 56 | 57 | iceConnected, iceConnectedCancel := context.WithCancel(context.TODO()) 58 | iceFailed, iceFailedCancel := context.WithCancel(context.TODO()) 59 | 60 | peerConnection.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { 61 | switch s { 62 | case webrtc.ICEConnectionStateFailed: 63 | iceFailedCancel() 64 | case webrtc.ICEConnectionStateConnected: 65 | iceConnectedCancel() 66 | } 67 | }) 68 | 69 | req := httptest.NewRequest("POST", "/api/whip", strings.NewReader(offer.SDP)) 70 | req.Header["Authorization"] = []string{"Bearer networktest"} 71 | recorder := httptest.NewRecorder() 72 | 73 | whepHandler(recorder, req) 74 | res := recorder.Result() 75 | 76 | if res.StatusCode != 201 { 77 | return fmt.Errorf("unexpected HTTP StatusCode %d", res.StatusCode) 78 | } 79 | 80 | if contentType := res.Header.Get("Content-Type"); contentType != "application/sdp" { 81 | return fmt.Errorf("unexpected HTTP Content-Type %s", contentType) 82 | } 83 | 84 | respBody, _ := io.ReadAll(res.Body) 85 | 86 | answerParsed := sdp.SessionDescription{} 87 | if err = answerParsed.Unmarshal(respBody); err != nil { 88 | return err 89 | } 90 | 91 | firstMediaSection := answerParsed.MediaDescriptions[0] 92 | filteredAttributes := []sdp.Attribute{} 93 | for i := range firstMediaSection.Attributes { 94 | a := firstMediaSection.Attributes[i] 95 | 96 | if a.Key == "candidate" { 97 | c, err := ice.UnmarshalCandidate(a.Value) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | ip := net.ParseIP(c.Address()) 103 | if ip == nil { 104 | return fmt.Errorf("candidate with invalid IP %s", c.Address()) 105 | } 106 | 107 | if !ip.IsPrivate() { 108 | filteredAttributes = append(filteredAttributes, a) 109 | } 110 | } else { 111 | filteredAttributes = append(filteredAttributes, a) 112 | } 113 | } 114 | firstMediaSection.Attributes = filteredAttributes 115 | 116 | answer, err := answerParsed.Marshal() 117 | if err != nil { 118 | return err 119 | } 120 | 121 | if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{ 122 | Type: webrtc.SDPTypeAnswer, 123 | SDP: string(answer), 124 | }); err != nil { 125 | return err 126 | } 127 | 128 | select { 129 | case <-iceConnected.Done(): 130 | _ = peerConnection.Close() 131 | return nil 132 | case <-iceFailed.Done(): 133 | _ = peerConnection.Close() 134 | 135 | return errors.New("network Test client failed to connect to Broadcast Box") 136 | case <-time.After(time.Second * 30): 137 | _ = peerConnection.Close() 138 | 139 | return errors.New("network Test client reported nothing in 30 seconds") 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /web/src/components/selection/Frontpage.tsx: -------------------------------------------------------------------------------- 1 | import React, {createRef, useState} from 'react' 2 | import {useNavigate} from 'react-router-dom' 3 | import AvailableStreams from "./AvailableStreams"; 4 | 5 | const Frontpage = () => { 6 | const [streamType, setStreamType] = useState<'Watch' | 'Share'>('Watch'); 7 | const streamKey = createRef() 8 | const navigate = useNavigate() 9 | 10 | const onStreamClick = () => { 11 | if(!streamKey.current || streamKey.current?.value === ''){ 12 | return; 13 | } 14 | 15 | if(streamType === "Share"){ 16 | navigate(`/publish/${streamKey.current.value}`) 17 | } 18 | 19 | if(streamType === "Watch"){ 20 | navigate(`/${streamKey.current.value}`) 21 | } 22 | } 23 | 24 | return ( 25 |
26 |
27 |

Welcome to Broadcast Box

28 |

Broadcast Box is a tool that allows you to efficiently stream high-quality video in real time, using the latest in video codecs and WebRTC technology.

29 | 30 |
31 | 32 | 41 | 50 | 51 |
52 | 53 |
54 | 57 | 58 | { 64 | if(e.key === "Enter"){ 65 | onStreamClick() 66 | } 67 | })} 68 | ref={streamKey} 69 | autoFocus/> 70 | 71 | 78 |
79 | 80 | 81 |
82 |
83 | ) 84 | } 85 | 86 | export default Frontpage -------------------------------------------------------------------------------- /internal/webrtc/whip.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "log" 7 | "math" 8 | "strings" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "github.com/pion/rtcp" 13 | "github.com/pion/rtp" 14 | "github.com/pion/rtp/codecs" 15 | "github.com/pion/webrtc/v4" 16 | ) 17 | 18 | func audioWriter(remoteTrack *webrtc.TrackRemote, stream *stream) { 19 | rtpBuf := make([]byte, 1500) 20 | for { 21 | rtpRead, _, err := remoteTrack.Read(rtpBuf) 22 | switch { 23 | case errors.Is(err, io.EOF): 24 | return 25 | case err != nil: 26 | log.Println(err) 27 | return 28 | } 29 | 30 | stream.audioPacketsReceived.Add(1) 31 | if _, writeErr := stream.audioTrack.Write(rtpBuf[:rtpRead]); writeErr != nil && !errors.Is(writeErr, io.ErrClosedPipe) { 32 | log.Println(writeErr) 33 | return 34 | } 35 | } 36 | } 37 | 38 | func videoWriter(remoteTrack *webrtc.TrackRemote, stream *stream, peerConnection *webrtc.PeerConnection, s *stream, sessionId string) { 39 | id := remoteTrack.RID() 40 | if id == "" { 41 | id = videoTrackLabelDefault 42 | } 43 | 44 | videoTrack, err := addTrack(s, id, sessionId) 45 | if err != nil { 46 | log.Println(err) 47 | return 48 | } 49 | 50 | go func() { 51 | for { 52 | select { 53 | case <-stream.whipActiveContext.Done(): 54 | return 55 | case <-stream.pliChan: 56 | if sendErr := peerConnection.WriteRTCP([]rtcp.Packet{ 57 | &rtcp.PictureLossIndication{ 58 | MediaSSRC: uint32(remoteTrack.SSRC()), 59 | }, 60 | }); sendErr != nil { 61 | return 62 | } 63 | } 64 | } 65 | }() 66 | 67 | rtpBuf := make([]byte, 1500) 68 | rtpPkt := &rtp.Packet{} 69 | codec := getVideoTrackCodec(remoteTrack.Codec().MimeType) 70 | 71 | var depacketizer rtp.Depacketizer 72 | switch codec { 73 | case videoTrackCodecH264: 74 | depacketizer = &codecs.H264Packet{} 75 | case videoTrackCodecVP8: 76 | depacketizer = &codecs.VP8Packet{} 77 | case videoTrackCodecVP9: 78 | depacketizer = &codecs.VP9Packet{} 79 | } 80 | 81 | lastTimestamp := uint32(0) 82 | lastTimestampSet := false 83 | 84 | lastSequenceNumber := uint16(0) 85 | lastSequenceNumberSet := false 86 | 87 | for { 88 | rtpRead, _, err := remoteTrack.Read(rtpBuf) 89 | switch { 90 | case errors.Is(err, io.EOF): 91 | return 92 | case err != nil: 93 | log.Println(err) 94 | return 95 | } 96 | 97 | if err = rtpPkt.Unmarshal(rtpBuf[:rtpRead]); err != nil { 98 | log.Println(err) 99 | return 100 | } 101 | 102 | videoTrack.packetsReceived.Add(1) 103 | 104 | // Keyframe detection has only been implemented for H264 105 | isKeyframe := isKeyframe(rtpPkt, codec, depacketizer) 106 | if isKeyframe && codec == videoTrackCodecH264 { 107 | videoTrack.lastKeyFrameSeen.Store(time.Now()) 108 | } 109 | 110 | rtpPkt.Extension = false 111 | rtpPkt.Extensions = nil 112 | 113 | timeDiff := int64(rtpPkt.Timestamp) - int64(lastTimestamp) 114 | switch { 115 | case !lastTimestampSet: 116 | timeDiff = 0 117 | lastTimestampSet = true 118 | case timeDiff < -(math.MaxUint32 / 10): 119 | timeDiff += (math.MaxUint32 + 1) 120 | } 121 | 122 | sequenceDiff := int(rtpPkt.SequenceNumber) - int(lastSequenceNumber) 123 | switch { 124 | case !lastSequenceNumberSet: 125 | lastSequenceNumberSet = true 126 | sequenceDiff = 0 127 | case sequenceDiff < -(math.MaxUint16 / 10): 128 | sequenceDiff += (math.MaxUint16 + 1) 129 | } 130 | 131 | lastTimestamp = rtpPkt.Timestamp 132 | lastSequenceNumber = rtpPkt.SequenceNumber 133 | 134 | s.whepSessionsLock.RLock() 135 | for i := range s.whepSessions { 136 | s.whepSessions[i].sendVideoPacket(rtpPkt, id, timeDiff, sequenceDiff, codec, isKeyframe) 137 | } 138 | s.whepSessionsLock.RUnlock() 139 | 140 | } 141 | } 142 | 143 | func WHIP(offer, streamKey string) (string, error) { 144 | maybePrintOfferAnswer(offer, true) 145 | 146 | whipSessionId := uuid.New().String() 147 | 148 | peerConnection, err := newPeerConnection(apiWhip) 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | streamMapLock.Lock() 154 | defer streamMapLock.Unlock() 155 | stream, err := getStream(streamKey, whipSessionId) 156 | if err != nil { 157 | return "", err 158 | } 159 | 160 | peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) { 161 | if strings.HasPrefix(remoteTrack.Codec().MimeType, "audio") { 162 | audioWriter(remoteTrack, stream) 163 | } else { 164 | videoWriter(remoteTrack, stream, peerConnection, stream, whipSessionId) 165 | 166 | } 167 | }) 168 | 169 | peerConnection.OnICEConnectionStateChange(func(i webrtc.ICEConnectionState) { 170 | if i == webrtc.ICEConnectionStateFailed || i == webrtc.ICEConnectionStateClosed { 171 | if err := peerConnection.Close(); err != nil { 172 | log.Println(err) 173 | } 174 | peerConnectionDisconnected(true, streamKey, whipSessionId) 175 | } 176 | }) 177 | 178 | if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{ 179 | SDP: string(offer), 180 | Type: webrtc.SDPTypeOffer, 181 | }); err != nil { 182 | return "", err 183 | } 184 | 185 | gatherComplete := webrtc.GatheringCompletePromise(peerConnection) 186 | answer, err := peerConnection.CreateAnswer(nil) 187 | 188 | if err != nil { 189 | return "", err 190 | } else if err = peerConnection.SetLocalDescription(answer); err != nil { 191 | return "", err 192 | } 193 | 194 | <-gatherComplete 195 | return maybePrintOfferAnswer(appendAnswer(peerConnection.LocalDescription().SDP), false), nil 196 | } 197 | -------------------------------------------------------------------------------- /internal/webrtc/whep.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "log" 8 | "sync/atomic" 9 | 10 | "github.com/google/uuid" 11 | "github.com/pion/rtcp" 12 | "github.com/pion/rtp" 13 | "github.com/pion/webrtc/v4" 14 | ) 15 | 16 | type ( 17 | whepSession struct { 18 | videoTrack *trackMultiCodec 19 | currentLayer atomic.Value 20 | waitingForKeyframe atomic.Bool 21 | sequenceNumber uint16 22 | timestamp uint32 23 | packetsWritten uint64 24 | } 25 | 26 | simulcastLayerResponse struct { 27 | EncodingId string `json:"encodingId"` 28 | } 29 | ) 30 | 31 | func WHEPLayers(whepSessionId string) ([]byte, error) { 32 | streamMapLock.Lock() 33 | defer streamMapLock.Unlock() 34 | 35 | layers := []simulcastLayerResponse{} 36 | for streamKey := range streamMap { 37 | streamMap[streamKey].whepSessionsLock.Lock() 38 | defer streamMap[streamKey].whepSessionsLock.Unlock() 39 | 40 | if _, ok := streamMap[streamKey].whepSessions[whepSessionId]; ok { 41 | for i := range streamMap[streamKey].videoTracks { 42 | layers = append(layers, simulcastLayerResponse{EncodingId: streamMap[streamKey].videoTracks[i].rid}) 43 | } 44 | 45 | break 46 | } 47 | } 48 | 49 | resp := map[string]map[string][]simulcastLayerResponse{ 50 | "1": map[string][]simulcastLayerResponse{ 51 | "layers": layers, 52 | }, 53 | } 54 | 55 | return json.Marshal(resp) 56 | } 57 | 58 | func WHEPChangeLayer(whepSessionId, layer string) error { 59 | streamMapLock.Lock() 60 | defer streamMapLock.Unlock() 61 | 62 | for streamKey := range streamMap { 63 | streamMap[streamKey].whepSessionsLock.Lock() 64 | defer streamMap[streamKey].whepSessionsLock.Unlock() 65 | 66 | if _, ok := streamMap[streamKey].whepSessions[whepSessionId]; ok { 67 | streamMap[streamKey].whepSessions[whepSessionId].currentLayer.Store(layer) 68 | streamMap[streamKey].whepSessions[whepSessionId].waitingForKeyframe.Store(true) 69 | streamMap[streamKey].pliChan <- true 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func WHEP(offer, streamKey string) (string, string, error) { 77 | maybePrintOfferAnswer(offer, true) 78 | 79 | streamMapLock.Lock() 80 | defer streamMapLock.Unlock() 81 | stream, err := getStream(streamKey, "") 82 | if err != nil { 83 | return "", "", err 84 | } 85 | 86 | whepSessionId := uuid.New().String() 87 | 88 | videoTrack := &trackMultiCodec{id: "video", streamID: "pion"} 89 | 90 | peerConnection, err := newPeerConnection(apiWhep) 91 | if err != nil { 92 | return "", "", err 93 | } 94 | 95 | peerConnection.OnICEConnectionStateChange(func(i webrtc.ICEConnectionState) { 96 | if i == webrtc.ICEConnectionStateFailed || i == webrtc.ICEConnectionStateClosed { 97 | if err := peerConnection.Close(); err != nil { 98 | log.Println(err) 99 | } 100 | 101 | peerConnectionDisconnected(false, streamKey, whepSessionId) 102 | } 103 | }) 104 | 105 | if _, err = peerConnection.AddTrack(stream.audioTrack); err != nil { 106 | return "", "", err 107 | } 108 | 109 | rtpSender, err := peerConnection.AddTrack(videoTrack) 110 | if err != nil { 111 | return "", "", err 112 | } 113 | 114 | go func() { 115 | for { 116 | rtcpPackets, _, rtcpErr := rtpSender.ReadRTCP() 117 | if rtcpErr != nil { 118 | return 119 | } 120 | 121 | for _, r := range rtcpPackets { 122 | if _, isPLI := r.(*rtcp.PictureLossIndication); isPLI { 123 | select { 124 | case stream.pliChan <- true: 125 | default: 126 | } 127 | } 128 | } 129 | } 130 | }() 131 | 132 | if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{ 133 | SDP: offer, 134 | Type: webrtc.SDPTypeOffer, 135 | }); err != nil { 136 | return "", "", err 137 | } 138 | 139 | gatherComplete := webrtc.GatheringCompletePromise(peerConnection) 140 | answer, err := peerConnection.CreateAnswer(nil) 141 | 142 | if err != nil { 143 | return "", "", err 144 | } else if err = peerConnection.SetLocalDescription(answer); err != nil { 145 | return "", "", err 146 | } 147 | 148 | <-gatherComplete 149 | 150 | stream.whepSessionsLock.Lock() 151 | defer stream.whepSessionsLock.Unlock() 152 | 153 | stream.whepSessions[whepSessionId] = &whepSession{ 154 | videoTrack: videoTrack, 155 | timestamp: 50000, 156 | } 157 | stream.whepSessions[whepSessionId].currentLayer.Store("") 158 | stream.whepSessions[whepSessionId].waitingForKeyframe.Store(false) 159 | 160 | return maybePrintOfferAnswer(appendAnswer(peerConnection.LocalDescription().SDP), false), whepSessionId, nil 161 | } 162 | 163 | func (w *whepSession) sendVideoPacket(rtpPkt *rtp.Packet, layer string, timeDiff int64, sequenceDiff int, codec videoTrackCodec, isKeyframe bool) { 164 | // Skip if video track is not available (e.g., audio-only) 165 | if w.videoTrack == nil || w.videoTrack.writeStream == nil { 166 | return 167 | } 168 | 169 | if w.currentLayer.Load() == "" { 170 | w.currentLayer.Store(layer) 171 | } else if layer != w.currentLayer.Load() { 172 | return 173 | } else if w.waitingForKeyframe.Load() { 174 | if !isKeyframe { 175 | return 176 | } 177 | 178 | w.waitingForKeyframe.Store(false) 179 | } 180 | 181 | w.packetsWritten += 1 182 | w.sequenceNumber = uint16(int(w.sequenceNumber) + sequenceDiff) 183 | w.timestamp = uint32(int64(w.timestamp) + timeDiff) 184 | 185 | rtpPkt.SequenceNumber = w.sequenceNumber 186 | rtpPkt.Timestamp = w.timestamp 187 | 188 | if err := w.videoTrack.WriteRTP(rtpPkt, codec); err != nil && !errors.Is(err, io.ErrClosedPipe) { 189 | log.Println(err) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /examples/recording/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/pion/webrtc/v4" 15 | "github.com/pion/webrtc/v4/pkg/media" 16 | "github.com/pion/webrtc/v4/pkg/media/h264writer" 17 | "github.com/pion/webrtc/v4/pkg/media/oggwriter" 18 | ) 19 | 20 | type webhookPayload struct { 21 | Action string `json:"action"` 22 | IP string `json:"ip"` 23 | BearerToken string `json:"bearerToken"` 24 | QueryParams map[string]string `json:"queryParams"` 25 | UserAgent string `json:"userAgent"` 26 | } 27 | 28 | type webhookResponse struct { 29 | StreamKey string `json:"streamKey"` 30 | } 31 | 32 | const ( 33 | whepServerUrl = "http://127.0.0.1:8080/api/whep" 34 | fileNameLength = 16 35 | readTimeout = time.Second * 5 36 | ) 37 | 38 | func startRecording(streamKey string) { 39 | s := webrtc.SettingEngine{} 40 | s.SetNetworkTypes([]webrtc.NetworkType{ 41 | webrtc.NetworkTypeUDP4, 42 | webrtc.NetworkTypeUDP6, 43 | webrtc.NetworkTypeTCP4, 44 | webrtc.NetworkTypeTCP6, 45 | }) 46 | 47 | peerConnection, err := webrtc.NewAPI(webrtc.WithSettingEngine(s)).NewPeerConnection(webrtc.Configuration{}) 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { 53 | panic(err) 54 | } 55 | 56 | if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil { 57 | panic(err) 58 | } 59 | 60 | offer, err := peerConnection.CreateOffer(nil) 61 | if err != nil { 62 | panic(err) 63 | } 64 | 65 | if err = peerConnection.SetLocalDescription(offer); err != nil { 66 | panic(err) 67 | } 68 | 69 | req, err := http.NewRequest("POST", whepServerUrl, bytes.NewBuffer([]byte(offer.SDP))) 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | req.Header.Set("Authorization", "Bearer "+streamKey) 75 | req.Header.Set("Content-Type", "application/sdp") 76 | 77 | resp, err := http.DefaultClient.Do(req) 78 | if err != nil { 79 | panic(err) 80 | } 81 | defer resp.Body.Close() // nolint 82 | 83 | if resp.StatusCode != 201 { 84 | panic(fmt.Sprintf("unexpected HTTP StatusCode %d", resp.StatusCode)) 85 | } 86 | 87 | if resp.Header.Get("Content-Type") != "application/sdp" { 88 | panic(fmt.Sprintf("unexpected HTTP Content-Type %s", resp.Header.Get("Content-Type"))) 89 | } 90 | 91 | respBody, err := io.ReadAll(resp.Body) 92 | if err != nil { 93 | panic(err) 94 | } 95 | 96 | if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{ 97 | Type: webrtc.SDPTypeAnswer, 98 | SDP: string(respBody), 99 | }); err != nil { 100 | panic(err) 101 | } 102 | 103 | prefix, audioWriter, videoWriter := createFiles() 104 | 105 | peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { 106 | log.Printf("Recording %s Connection State has changed to %s \n", streamKey, state) 107 | switch state { 108 | case webrtc.PeerConnectionStateFailed: 109 | _ = peerConnection.Close() 110 | case webrtc.PeerConnectionStateClosed: 111 | _ = audioWriter.Close() 112 | _ = videoWriter.Close() 113 | } 114 | }) 115 | 116 | peerConnection.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { 117 | if strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus) { 118 | fmt.Printf("Got Opus track, saving to disk as %s.ogg (48 kHz, 2 channels)\n", prefix) 119 | saveToDisk(audioWriter, track) 120 | } else if strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeH264) { 121 | fmt.Printf("Got H264 track, saving to disk as %s.h264\n", prefix) 122 | saveToDisk(videoWriter, track) 123 | } 124 | }) 125 | } 126 | 127 | func main() { 128 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 129 | if r.Method != "POST" { 130 | http.Error(w, "Only POST method is accepted", http.StatusMethodNotAllowed) 131 | return 132 | } 133 | 134 | var payload webhookPayload 135 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 136 | http.Error(w, "Invalid JSON", http.StatusBadRequest) 137 | return 138 | } 139 | 140 | w.WriteHeader(http.StatusOK) 141 | if err := json.NewEncoder(w).Encode(webhookResponse{StreamKey: payload.BearerToken}); err != nil { 142 | http.Error(w, err.Error(), http.StatusInternalServerError) 143 | } 144 | 145 | if payload.Action == "whip-connect" { 146 | startRecording(payload.BearerToken) 147 | } 148 | }) 149 | 150 | log.Println("Server listening on port 8081") 151 | if err := http.ListenAndServe("127.0.0.1:8081", nil); err != nil { 152 | log.Fatalf("Could not start server: %s\n", err) 153 | } 154 | } 155 | 156 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 157 | 158 | func createFiles() (string, media.Writer, media.Writer) { 159 | prefix := make([]rune, fileNameLength) 160 | for i := range prefix { 161 | prefix[i] = letterRunes[rand.Intn(len(letterRunes))] 162 | } 163 | 164 | audioFile, err := oggwriter.New(string(prefix)+".ogg", 48000, 2) 165 | if err != nil { 166 | panic(err) 167 | } 168 | videoFile, err := h264writer.New(string(prefix) + ".h264") 169 | if err != nil { 170 | panic(err) 171 | } 172 | 173 | return string(prefix), audioFile, videoFile 174 | } 175 | 176 | func saveToDisk(writer media.Writer, track *webrtc.TrackRemote) { 177 | defer func() { 178 | if err := writer.Close(); err != nil { 179 | panic(err) 180 | } 181 | }() 182 | 183 | for { 184 | _ = track.SetReadDeadline(time.Now().Add(readTimeout)) 185 | rtpPacket, _, err := track.ReadRTP() 186 | if err != nil { 187 | fmt.Println(err) 188 | return 189 | } 190 | 191 | if err := writer.WriteRTP(rtpPacket); err != nil { 192 | fmt.Println(err) 193 | return 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /web/src/components/broadcast/Broadcast.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useRef, useState} from 'react' 2 | import {useLocation} from 'react-router-dom' 3 | import {useNavigate} from 'react-router-dom' 4 | import PlayerHeader from '../playerHeader/PlayerHeader'; 5 | import {StatusContext} from "../../providers/StatusProvider"; 6 | import {UsersIcon} from "@heroicons/react/20/solid"; 7 | 8 | const mediaOptions = { 9 | audio: true, 10 | video: { 11 | width: { ideal: 1920 }, 12 | height: { ideal: 1080 }, 13 | }, 14 | } 15 | 16 | enum ErrorMessageEnum { 17 | NoMediaDevices, 18 | NotAllowedError, 19 | NotFoundError 20 | } 21 | 22 | function getMediaErrorMessage(value: ErrorMessageEnum): string { 23 | switch (value) { 24 | case ErrorMessageEnum.NoMediaDevices: 25 | return `MediaDevices API was not found. Publishing in Broadcast Box requires HTTPS 👮`; 26 | case ErrorMessageEnum.NotFoundError: 27 | return `Seems like you don't have camera 😭 Or you just blocked access to it...\nCheck camera settings, browser permissions and system permissions.`; 28 | case ErrorMessageEnum.NotAllowedError: 29 | return `You can't publish stream using your camera, because you have blocked access to it 😞`; 30 | default: 31 | return "Could not access your media device"; 32 | } 33 | } 34 | 35 | function BrowserBroadcaster() { 36 | const location = useLocation() 37 | const navigate = useNavigate(); 38 | const streamKey = location.pathname.split('/').pop() 39 | const { streamStatus } = useContext(StatusContext); 40 | const [mediaAccessError, setMediaAccessError] = useState(null) 41 | const [publishSuccess, setPublishSuccess] = useState(false) 42 | const [useDisplayMedia, setUseDisplayMedia] = useState<"Screen" | "Webcam" | "None">("None"); 43 | const [peerConnectionDisconnected, setPeerConnectionDisconnected] = useState(false) 44 | const [currentViewersCount, setCurrentViewersCount] = useState(0) 45 | const [hasPacketLoss, setHasPacketLoss] = useState(false) 46 | const [hasSignal, setHasSignal] = useState(false); 47 | const [connectFailed, setConnectFailed] = useState(false) 48 | 49 | const peerConnectionRef = useRef(null); 50 | const videoRef = useRef(null) 51 | const hasSignalRef = useRef(false); 52 | const badSignalCountRef = useRef(10); 53 | 54 | const apiPath = import.meta.env.VITE_API_PATH; 55 | 56 | const endStream = () => { 57 | navigate('/') 58 | } 59 | 60 | useEffect(() => { 61 | peerConnectionRef.current = new RTCPeerConnection(); 62 | 63 | return () => peerConnectionRef.current?.close() 64 | }, []) 65 | 66 | useEffect(() => { 67 | if(!streamKey || !streamStatus){ 68 | return; 69 | } 70 | 71 | const sessions = streamStatus.filter((session) => session.streamKey === streamKey); 72 | 73 | if(sessions.length !== 0){ 74 | setCurrentViewersCount(() => 75 | sessions.length !== 0 76 | ? sessions[0].whepSessions.length 77 | : 0) 78 | } 79 | }, [streamStatus]); 80 | 81 | useEffect(() => { 82 | if (useDisplayMedia === "None" || !peerConnectionRef.current) { 83 | return; 84 | } 85 | 86 | let stream: MediaStream | undefined = undefined; 87 | 88 | if (!navigator.mediaDevices) { 89 | setMediaAccessError(() => ErrorMessageEnum.NoMediaDevices); 90 | setUseDisplayMedia(() => "None") 91 | return 92 | } 93 | 94 | const isScreenShare = useDisplayMedia === "Screen" 95 | const mediaPromise = isScreenShare ? 96 | navigator.mediaDevices.getDisplayMedia(mediaOptions) : 97 | navigator.mediaDevices.getUserMedia(mediaOptions) 98 | 99 | mediaPromise.then(mediaStream => { 100 | if (peerConnectionRef.current!.connectionState === "closed") { 101 | mediaStream 102 | .getTracks() 103 | .forEach(mediaStreamTrack => mediaStreamTrack.stop()) 104 | 105 | return; 106 | } 107 | 108 | stream = mediaStream 109 | videoRef.current!.srcObject = mediaStream 110 | 111 | mediaStream 112 | .getTracks() 113 | .forEach(mediaStreamTrack => { 114 | if (mediaStreamTrack.kind === 'audio') { 115 | peerConnectionRef.current!.addTransceiver(mediaStreamTrack, { 116 | direction: 'sendonly' 117 | }) 118 | } else { 119 | peerConnectionRef.current!.addTransceiver(mediaStreamTrack, { 120 | direction: 'sendonly', 121 | sendEncodings: isScreenShare ? [] : [ 122 | { 123 | rid: 'high', 124 | }, 125 | { 126 | rid: 'med', 127 | scaleResolutionDownBy: 2.0 128 | }, 129 | { 130 | rid: 'low', 131 | scaleResolutionDownBy: 4.0 132 | } 133 | ] 134 | }) 135 | } 136 | }) 137 | 138 | peerConnectionRef.current!.oniceconnectionstatechange = () => { 139 | if (peerConnectionRef.current!.iceConnectionState === 'connected' || peerConnectionRef.current!.iceConnectionState === 'completed') { 140 | setPublishSuccess(() => true) 141 | setMediaAccessError(() => null) 142 | setPeerConnectionDisconnected(() => false) 143 | } else if (peerConnectionRef.current!.iceConnectionState === 'disconnected' || peerConnectionRef.current!.iceConnectionState === 'failed') { 144 | setPublishSuccess(() => false) 145 | setPeerConnectionDisconnected(() => true) 146 | } 147 | } 148 | 149 | peerConnectionRef 150 | .current! 151 | .createOffer() 152 | .then(offer => { 153 | peerConnectionRef.current!.setLocalDescription(offer) 154 | .catch((err) => console.error("SetLocalDescription", err)); 155 | 156 | fetch(`${apiPath}/whip`, { 157 | method: 'POST', 158 | body: offer.sdp, 159 | headers: { 160 | Authorization: `Bearer ${streamKey}`, 161 | 'Content-Type': 'application/sdp' 162 | } 163 | }).then(r => { 164 | setConnectFailed(r.status !== 201) 165 | if (connectFailed) { 166 | throw new DOMException("WHIP endpoint did not return 201"); 167 | } 168 | 169 | return r.text() 170 | }) 171 | .then(answer => { 172 | peerConnectionRef.current!.setRemoteDescription({ 173 | sdp: answer, 174 | type: 'answer' 175 | }) 176 | .catch((err) => console.error("SetRemoveDescription", err)) 177 | }) 178 | }) 179 | }, (reason: ErrorMessageEnum) => { 180 | setMediaAccessError(() => reason) 181 | setUseDisplayMedia("None"); 182 | }) 183 | 184 | return () => { 185 | peerConnectionRef.current?.close() 186 | if (stream) { 187 | stream 188 | .getTracks() 189 | .forEach((streamTrack: MediaStreamTrack) => streamTrack.stop()) 190 | } 191 | } 192 | }, [videoRef, useDisplayMedia, location.pathname]) 193 | 194 | useEffect(() => { 195 | hasSignalRef.current = hasSignal; 196 | 197 | const intervalHandler = () => { 198 | let senderHasPacketLoss = false; 199 | peerConnectionRef.current?.getSenders().forEach(sender => { 200 | if (sender) { 201 | sender.getStats() 202 | .then(stats => { 203 | stats.forEach(report => { 204 | if (report.type === "outbound-rtp") { 205 | senderHasPacketLoss = report.totalPacketSendDelay > 10; 206 | } 207 | if (report.type === "candidate-pair") { 208 | const signalIsValid = report.availableIncomingBitrate !== undefined; 209 | badSignalCountRef.current = signalIsValid ? 0 : badSignalCountRef.current + 1; 210 | 211 | if (badSignalCountRef.current > 2) { 212 | setHasSignal(() => false); 213 | } else if (badSignalCountRef.current === 0 && !hasSignalRef.current) { 214 | setHasSignal(() => true); 215 | } 216 | } 217 | } 218 | ) 219 | }) 220 | } 221 | }) 222 | 223 | setHasPacketLoss(() => senderHasPacketLoss); 224 | } 225 | 226 | const interval = setInterval(intervalHandler, hasSignal ? 15_000 : 2_500) 227 | 228 | return () => { 229 | clearInterval(interval); 230 | } 231 | }, [hasSignal]); 232 | 233 | return ( 234 |
235 | {mediaAccessError != null && {getMediaErrorMessage(mediaAccessError)} } 236 | {peerConnectionDisconnected && WebRTC has disconnected or failed to connect at all 😭 } 237 | {connectFailed && Failed to start Broadcast Box session 👮 } 238 | {hasPacketLoss && WebRTC is experiencing packet loss} 239 | {publishSuccess && Live: Currently streaming to {window.location.href.replace('publish/', '')} } 240 | 241 |
279 | ) 280 | } 281 | 282 | export default BrowserBroadcaster -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | "regexp" 15 | "strings" 16 | "time" 17 | 18 | "github.com/glimesh/broadcast-box/internal/networktest" 19 | "github.com/glimesh/broadcast-box/internal/webhook" 20 | "github.com/glimesh/broadcast-box/internal/webrtc" 21 | "github.com/joho/godotenv" 22 | ) 23 | 24 | const ( 25 | envFileProd = ".env.production" 26 | envFileDev = ".env.development" 27 | 28 | networkTestIntroMessage = "\033[0;33mNETWORK_TEST_ON_START is enabled. If the test fails Broadcast Box will exit.\nSee the README for how to debug or disable NETWORK_TEST_ON_START\033[0m" 29 | networkTestSuccessMessage = "\033[0;32mNetwork Test passed.\nHave fun using Broadcast Box.\033[0m" 30 | networkTestFailedMessage = "\033[0;31mNetwork Test failed.\n%s\nPlease see the README and join Discord for help\033[0m" 31 | ) 32 | 33 | var ( 34 | errNoBuildDirectoryErr = errors.New("\033[0;31mBuild directory does not exist, run `npm install` and `npm run build` in the web directory.\033[0m") 35 | errAuthorizationNotSet = errors.New("authorization was not set") 36 | errInvalidStreamKey = errors.New("invalid stream key format") 37 | 38 | streamKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.~]+$`) 39 | ) 40 | 41 | type ( 42 | whepLayerRequestJSON struct { 43 | MediaId string `json:"mediaId"` 44 | EncodingId string `json:"encodingId"` 45 | } 46 | ) 47 | 48 | func getStreamKey(action string, r *http.Request) (streamKey string, err error) { 49 | authorizationHeader := r.Header.Get("Authorization") 50 | if authorizationHeader == "" { 51 | return "", errAuthorizationNotSet 52 | } 53 | 54 | const bearerPrefix = "Bearer " 55 | if !strings.HasPrefix(authorizationHeader, bearerPrefix) { 56 | return "", errInvalidStreamKey 57 | } 58 | 59 | streamKey = strings.TrimPrefix(authorizationHeader, bearerPrefix) 60 | if webhookUrl := os.Getenv("WEBHOOK_URL"); webhookUrl != "" { 61 | streamKey, err = webhook.CallWebhook(webhookUrl, action, streamKey, r) 62 | if err != nil { 63 | return "", err 64 | } 65 | } 66 | 67 | if !streamKeyRegex.MatchString(streamKey) { 68 | return "", errInvalidStreamKey 69 | } 70 | 71 | return streamKey, nil 72 | } 73 | 74 | func logHTTPError(w http.ResponseWriter, err string, code int) { 75 | log.Println(err) 76 | http.Error(w, err, code) 77 | } 78 | 79 | func whipHandler(res http.ResponseWriter, r *http.Request) { 80 | if r.Method != "POST" { 81 | return 82 | } 83 | 84 | streamKey, err := getStreamKey("whip-connect", r) 85 | if err != nil { 86 | logHTTPError(res, err.Error(), http.StatusBadRequest) 87 | return 88 | } 89 | 90 | offer, err := io.ReadAll(r.Body) 91 | if err != nil { 92 | logHTTPError(res, err.Error(), http.StatusBadRequest) 93 | return 94 | } 95 | 96 | answer, err := webrtc.WHIP(string(offer), streamKey) 97 | if err != nil { 98 | logHTTPError(res, err.Error(), http.StatusBadRequest) 99 | return 100 | } 101 | 102 | res.Header().Add("Location", "/api/whip") 103 | res.Header().Add("Content-Type", "application/sdp") 104 | res.WriteHeader(http.StatusCreated) 105 | if _, err = fmt.Fprint(res, answer); err != nil { 106 | log.Println(err) 107 | } 108 | } 109 | 110 | func whepHandler(res http.ResponseWriter, req *http.Request) { 111 | if req.Method != "POST" { 112 | return 113 | } 114 | 115 | streamKey, err := getStreamKey("whep-connect", req) 116 | if err != nil { 117 | logHTTPError(res, err.Error(), http.StatusBadRequest) 118 | return 119 | } 120 | 121 | offer, err := io.ReadAll(req.Body) 122 | if err != nil { 123 | logHTTPError(res, err.Error(), http.StatusBadRequest) 124 | return 125 | } 126 | 127 | answer, whepSessionId, err := webrtc.WHEP(string(offer), streamKey) 128 | if err != nil { 129 | logHTTPError(res, err.Error(), http.StatusBadRequest) 130 | return 131 | } 132 | 133 | apiPath := req.Host + strings.TrimSuffix(req.URL.RequestURI(), "whep") 134 | res.Header().Add("Link", `<`+apiPath+"sse/"+whepSessionId+`>; rel="urn:ietf:params:whep:ext:core:server-sent-events"; events="layers"`) 135 | res.Header().Add("Link", `<`+apiPath+"layer/"+whepSessionId+`>; rel="urn:ietf:params:whep:ext:core:layer"`) 136 | res.Header().Add("Location", "/api/whep") 137 | res.Header().Add("Content-Type", "application/sdp") 138 | res.WriteHeader(http.StatusCreated) 139 | if _, err = fmt.Fprint(res, answer); err != nil { 140 | log.Println(err) 141 | } 142 | } 143 | 144 | func whepServerSentEventsHandler(res http.ResponseWriter, req *http.Request) { 145 | res.Header().Set("Content-Type", "text/event-stream") 146 | res.Header().Set("Cache-Control", "no-cache") 147 | res.Header().Set("Connection", "keep-alive") 148 | 149 | vals := strings.Split(req.URL.RequestURI(), "/") 150 | whepSessionId := vals[len(vals)-1] 151 | 152 | layers, err := webrtc.WHEPLayers(whepSessionId) 153 | if err != nil { 154 | logHTTPError(res, err.Error(), http.StatusBadRequest) 155 | return 156 | } 157 | 158 | if _, err = fmt.Fprintf(res, "event: layers\ndata: %s\n\n\n", string(layers)); err != nil { 159 | log.Println(err) 160 | } 161 | } 162 | 163 | func whepLayerHandler(res http.ResponseWriter, req *http.Request) { 164 | var r whepLayerRequestJSON 165 | if err := json.NewDecoder(req.Body).Decode(&r); err != nil { 166 | logHTTPError(res, err.Error(), http.StatusBadRequest) 167 | return 168 | } 169 | 170 | vals := strings.Split(req.URL.RequestURI(), "/") 171 | whepSessionId := vals[len(vals)-1] 172 | 173 | if err := webrtc.WHEPChangeLayer(whepSessionId, r.EncodingId); err != nil { 174 | logHTTPError(res, err.Error(), http.StatusBadRequest) 175 | return 176 | } 177 | } 178 | 179 | func statusHandler(res http.ResponseWriter, req *http.Request) { 180 | if os.Getenv("DISABLE_STATUS") != "" { 181 | logHTTPError(res, "Status Service Unavailable", http.StatusServiceUnavailable) 182 | return 183 | } 184 | 185 | res.Header().Add("Content-Type", "application/json") 186 | 187 | if err := json.NewEncoder(res).Encode(webrtc.GetStreamStatuses()); err != nil { 188 | logHTTPError(res, err.Error(), http.StatusBadRequest) 189 | } 190 | } 191 | 192 | func indexHTMLWhenNotFound(fs http.FileSystem) http.Handler { 193 | fileServer := http.FileServer(fs) 194 | 195 | return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 196 | _, err := fs.Open(path.Clean(req.URL.Path)) // Do not allow path traversals. 197 | if errors.Is(err, os.ErrNotExist) { 198 | http.ServeFile(resp, req, "./web/build/index.html") 199 | 200 | return 201 | } 202 | fileServer.ServeHTTP(resp, req) 203 | }) 204 | } 205 | 206 | func corsHandler(next func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { 207 | return func(res http.ResponseWriter, req *http.Request) { 208 | res.Header().Set("Access-Control-Allow-Origin", "*") 209 | res.Header().Set("Access-Control-Allow-Methods", "*") 210 | res.Header().Set("Access-Control-Allow-Headers", "*") 211 | res.Header().Set("Access-Control-Expose-Headers", "*") 212 | 213 | if req.Method != http.MethodOptions { 214 | next(res, req) 215 | } 216 | } 217 | } 218 | 219 | func loadConfigs() error { 220 | if os.Getenv("APP_ENV") == "development" { 221 | log.Println("Loading `" + envFileDev + "`") 222 | return godotenv.Load(envFileDev) 223 | } else { 224 | log.Println("Loading `" + envFileProd + "`") 225 | if err := godotenv.Load(envFileProd); err != nil { 226 | return err 227 | } 228 | 229 | if _, err := os.Stat("./web/build"); os.IsNotExist(err) && os.Getenv("DISABLE_FRONTEND") == "" { 230 | return errNoBuildDirectoryErr 231 | } 232 | 233 | return nil 234 | } 235 | } 236 | 237 | func main() { 238 | if err := loadConfigs(); err != nil { 239 | log.Println("Failed to find config in CWD, changing CWD to executable path") 240 | 241 | exePath, err := os.Executable() 242 | if err != nil { 243 | log.Fatal(err) 244 | } 245 | 246 | if err = os.Chdir(filepath.Dir(exePath)); err != nil { 247 | log.Fatal(err) 248 | } 249 | 250 | if err = loadConfigs(); err != nil { 251 | log.Fatal(err) 252 | } 253 | } 254 | 255 | webrtc.Configure() 256 | 257 | if os.Getenv("NETWORK_TEST_ON_START") == "true" { 258 | fmt.Println(networkTestIntroMessage) //nolint 259 | 260 | go func() { 261 | time.Sleep(time.Second * 5) 262 | 263 | if networkTestErr := networktest.Run(whepHandler); networkTestErr != nil { 264 | fmt.Printf(networkTestFailedMessage, networkTestErr.Error()) 265 | os.Exit(1) 266 | } else { 267 | fmt.Println(networkTestSuccessMessage) //nolint 268 | } 269 | }() 270 | } 271 | 272 | httpsRedirectPort := "80" 273 | if val := os.Getenv("HTTPS_REDIRECT_PORT"); val != "" { 274 | httpsRedirectPort = val 275 | } 276 | 277 | if os.Getenv("HTTPS_REDIRECT_PORT") != "" || os.Getenv("ENABLE_HTTP_REDIRECT") != "" { 278 | go func() { 279 | redirectServer := &http.Server{ 280 | Addr: ":" + httpsRedirectPort, 281 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 | http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently) 283 | }), 284 | } 285 | 286 | log.Println("Running HTTP->HTTPS redirect Server at :" + httpsRedirectPort) 287 | log.Fatal(redirectServer.ListenAndServe()) 288 | }() 289 | } 290 | 291 | mux := http.NewServeMux() 292 | if os.Getenv("DISABLE_FRONTEND") == "" { 293 | mux.Handle("/", indexHTMLWhenNotFound(http.Dir("./web/build"))) 294 | } 295 | mux.HandleFunc("/api/whip", corsHandler(whipHandler)) 296 | mux.HandleFunc("/api/whep", corsHandler(whepHandler)) 297 | mux.HandleFunc("/api/sse/", corsHandler(whepServerSentEventsHandler)) 298 | mux.HandleFunc("/api/layer/", corsHandler(whepLayerHandler)) 299 | mux.HandleFunc("/api/status", corsHandler(statusHandler)) 300 | 301 | server := &http.Server{ 302 | Handler: mux, 303 | Addr: os.Getenv("HTTP_ADDRESS"), 304 | } 305 | 306 | tlsKey := os.Getenv("SSL_KEY") 307 | tlsCert := os.Getenv("SSL_CERT") 308 | 309 | if tlsKey != "" && tlsCert != "" { 310 | server.TLSConfig = &tls.Config{ 311 | Certificates: []tls.Certificate{}, 312 | } 313 | 314 | cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) 315 | if err != nil { 316 | log.Fatal(err) 317 | } 318 | 319 | server.TLSConfig.Certificates = append(server.TLSConfig.Certificates, cert) 320 | 321 | log.Println("Running HTTPS Server at `" + os.Getenv("HTTP_ADDRESS") + "`") 322 | log.Fatal(server.ListenAndServeTLS("", "")) 323 | } else { 324 | log.Println("Running HTTP Server at `" + os.Getenv("HTTP_ADDRESS") + "`") 325 | log.Fatal(server.ListenAndServe()) 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /web/src/components/player/Player.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from 'react' 2 | import {parseLinkHeader} from '@web3-storage/parse-link-header' 3 | import {ArrowsPointingOutIcon, Square2StackIcon} from "@heroicons/react/16/solid"; 4 | import VolumeComponent from "./components/VolumeComponent"; 5 | import PlayPauseComponent from "./components/PlayPauseComponent"; 6 | import QualitySelectorComponent from "./components/QualitySelectorComponent"; 7 | import CurrentViewersComponent from "./components/CurrentViewersComponent"; 8 | 9 | interface PlayerProps { 10 | streamKey: string; 11 | cinemaMode: boolean; 12 | onCloseStream?: () => void; 13 | } 14 | 15 | const Player = (props: PlayerProps) => { 16 | const apiPath = import.meta.env.VITE_API_PATH; 17 | const {streamKey, cinemaMode} = props; 18 | 19 | const [videoLayers, setVideoLayers] = useState([]); 20 | const [hasSignal, setHasSignal] = useState(false); 21 | const [hasPacketLoss, setHasPacketLoss] = useState(false) 22 | const [videoOverlayVisible, setVideoOverlayVisible] = useState(false) 23 | const [connectFailed, setConnectFailed] = useState(false) 24 | 25 | const videoRef = useRef(null); 26 | const layerEndpointRef = useRef(''); 27 | const hasSignalRef = useRef(false); 28 | const peerConnectionRef = useRef(null); 29 | const videoOverlayVisibleTimeoutRef = useRef(undefined); 30 | const clickDelay = 250; 31 | const lastClickTimeRef = useRef(0); 32 | const clickTimeoutRef = useRef(undefined); 33 | const streamVideoPlayerId = streamKey + "_videoPlayer"; 34 | 35 | const setHasSignalHandler = (_: Event) => { 36 | setHasSignal(() => true); 37 | } 38 | const resetTimer = (isVisible: boolean) => { 39 | setVideoOverlayVisible(() => isVisible); 40 | 41 | if(videoOverlayVisibleTimeoutRef){ 42 | clearTimeout(videoOverlayVisibleTimeoutRef.current) 43 | } 44 | 45 | videoOverlayVisibleTimeoutRef.current = setTimeout(() => { 46 | setVideoOverlayVisible(() => false) 47 | }, 2500) 48 | } 49 | 50 | const handleVideoPlayerClick = () => { 51 | lastClickTimeRef.current = Date.now(); 52 | 53 | clickTimeoutRef.current = setTimeout(() => { 54 | const timeSinceLastClick = Date.now() - lastClickTimeRef.current; 55 | if (timeSinceLastClick >= clickDelay && (timeSinceLastClick - clickDelay) < 5000) { 56 | videoRef.current?.paused 57 | ? videoRef.current?.play() 58 | : videoRef.current?.pause(); 59 | } 60 | }, clickDelay); 61 | }; 62 | const handleVideoPlayerDoubleClick = () => { 63 | clearTimeout(clickTimeoutRef.current); 64 | lastClickTimeRef.current = 0; 65 | videoRef.current?.requestFullscreen() 66 | .catch(err => console.error("VideoPlayer_RequestFullscreen", err)); 67 | }; 68 | 69 | useEffect(() => { 70 | const handleWindowBeforeUnload = () => { 71 | peerConnectionRef.current?.close(); 72 | peerConnectionRef.current = null; 73 | } 74 | 75 | const handleOverlayTimer = (isVisible: boolean) => resetTimer(isVisible); 76 | const player = document.getElementById(streamVideoPlayerId) 77 | 78 | player?.addEventListener('mousemove', () => handleOverlayTimer(true)) 79 | player?.addEventListener('mouseenter', () => handleOverlayTimer(true)) 80 | player?.addEventListener('mouseleave', () => handleOverlayTimer(false)) 81 | player?.addEventListener('mouseup', () => handleOverlayTimer(true)) 82 | 83 | window.addEventListener("beforeunload", handleWindowBeforeUnload) 84 | 85 | peerConnectionRef.current = new RTCPeerConnection(); 86 | 87 | return () => { 88 | peerConnectionRef.current?.close() 89 | peerConnectionRef.current = null 90 | 91 | videoRef.current?.removeEventListener("playing", setHasSignalHandler) 92 | 93 | player?.removeEventListener('mouseenter', () => handleOverlayTimer) 94 | player?.removeEventListener('mouseleave', () => handleOverlayTimer) 95 | player?.removeEventListener('mousemove', () => handleOverlayTimer) 96 | player?.removeEventListener('mouseup', () => handleOverlayTimer) 97 | 98 | window.removeEventListener("beforeunload", handleWindowBeforeUnload) 99 | 100 | clearTimeout(videoOverlayVisibleTimeoutRef.current) 101 | } 102 | }, []) 103 | 104 | useEffect(() => { 105 | hasSignalRef.current = hasSignal; 106 | 107 | const intervalHandler = () => { 108 | if (!peerConnectionRef.current) { 109 | return 110 | } 111 | 112 | let receiversHasPacketLoss = false; 113 | peerConnectionRef.current 114 | .getReceivers() 115 | .forEach(receiver => { 116 | if (receiver) { 117 | receiver.getStats() 118 | .then(stats => { 119 | stats.forEach(report => { 120 | if (report.type === "inbound-rtp") { 121 | const lossRate = report.packetsLost / (report.packetsLost + report.packetsReceived); 122 | receiversHasPacketLoss = receiversHasPacketLoss ? true : lossRate > 5; 123 | } 124 | } 125 | ) 126 | }) 127 | } 128 | }) 129 | 130 | setHasPacketLoss(() => receiversHasPacketLoss); 131 | } 132 | 133 | const interval = setInterval(intervalHandler, hasSignal ? 15_000 : 2_500) 134 | 135 | return () => clearInterval(interval); 136 | }, [hasSignal]); 137 | 138 | useEffect(() => { 139 | if (!peerConnectionRef.current) { 140 | return; 141 | } 142 | 143 | peerConnectionRef.current.ontrack = (event: RTCTrackEvent) => { 144 | if (videoRef.current) { 145 | videoRef.current.srcObject = event.streams[0]; 146 | videoRef.current.addEventListener("playing", setHasSignalHandler) 147 | } 148 | } 149 | 150 | peerConnectionRef.current.addTransceiver('audio', {direction: 'recvonly'}) 151 | peerConnectionRef.current.addTransceiver('video', {direction: 'recvonly'}) 152 | 153 | peerConnectionRef.current 154 | .createOffer() 155 | .then(offer => { 156 | offer["sdp"] = offer["sdp"]!.replace("useinbandfec=1", "useinbandfec=1;stereo=1") 157 | 158 | peerConnectionRef.current! 159 | .setLocalDescription(offer) 160 | .catch((err) => console.error("SetLocalDescription", err)); 161 | 162 | fetch(`${apiPath}/whep`, { 163 | method: 'POST', 164 | body: offer.sdp, 165 | headers: { 166 | Authorization: `Bearer ${streamKey}`, 167 | 'Content-Type': 'application/sdp' 168 | } 169 | }).then(r => { 170 | setConnectFailed(r.status !== 201) 171 | if (connectFailed) { 172 | throw new DOMException("WHEP endpoint did not return 201"); 173 | } 174 | 175 | const parsedLinkHeader = parseLinkHeader(r.headers.get('Link')) 176 | 177 | if (parsedLinkHeader === null || parsedLinkHeader === undefined) { 178 | throw new DOMException("Missing link header"); 179 | } 180 | 181 | layerEndpointRef.current = `${window.location.protocol}//${parsedLinkHeader['urn:ietf:params:whep:ext:core:layer'].url}` 182 | 183 | const evtSource = new EventSource(`${window.location.protocol}//${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`) 184 | evtSource.onerror = _ => evtSource.close(); 185 | 186 | evtSource.addEventListener("layers", event => { 187 | const parsed = JSON.parse(event.data) 188 | setVideoLayers(() => parsed['1']['layers'].map((layer: any) => layer.encodingId)) 189 | }) 190 | 191 | return r.text() 192 | }).then(answer => { 193 | peerConnectionRef.current!.setRemoteDescription({ 194 | sdp: answer, 195 | type: 'answer' 196 | }).catch((err) => console.error("RemoteDescription", err)) 197 | }).catch((err) => console.error("PeerConnectionError", err)) 198 | }) 199 | }, [peerConnectionRef]) 200 | 201 | return ( 202 |
209 | {connectFailed &&

Failed to start Broadcast Box session 👮

} 210 |
228 | 229 | {/*Opaque background*/} 230 |
231 | 232 | {/*Buttons */} 233 | {videoRef.current !== null && ( 234 |
235 |
e.stopPropagation()} 237 | className="bg-blue-950 w-full flex flex-row gap-2 h-1/14 rounded-b-md p-1 max-h-8 min-h-8"> 238 | 239 | 240 | 241 | videoRef.current!.volume = newValue} 244 | onStateChanged={(newState) => videoRef.current!.muted = newState} 245 | /> 246 | 247 |
248 | 249 | {hasSignal && } 250 | 251 | videoRef.current?.requestPictureInPicture()}/> 252 | videoRef.current?.requestFullscreen()}/> 253 | 254 |
255 |
)} 256 | 257 | {!!props.onCloseStream && ( 258 | 274 | )} 275 | 276 | {videoLayers.length === 0 && !hasSignal && ( 277 |

279 | {props.streamKey} is not currently streaming 280 |

281 | )} 282 | {videoLayers.length > 0 && !hasSignal && ( 283 |

285 | Loading video 286 |

287 | )} 288 | 289 |
290 | 291 |
299 | ) 300 | } 301 | 302 | export default Player 303 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 6 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 7 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 8 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 9 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 10 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 11 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 12 | github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= 13 | github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= 14 | github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= 15 | github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= 16 | github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= 17 | github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= 18 | github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= 19 | github.com/pion/ice/v3 v3.0.16 h1:YoPlNg3jU1UT/DDTa9v/g1vH6A2/pAzehevI1o66H8E= 20 | github.com/pion/ice/v3 v3.0.16/go.mod h1:SdmubtIsCcvdb1ZInrTUz7Iaqi90/rYd1pzbzlMxsZg= 21 | github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= 22 | github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= 23 | github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= 24 | github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= 25 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 26 | github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= 27 | github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= 28 | github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= 29 | github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 30 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= 31 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 32 | github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= 33 | github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= 34 | github.com/pion/rtp v1.8.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw= 35 | github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= 36 | github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= 37 | github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= 38 | github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= 39 | github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= 40 | github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= 41 | github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= 42 | github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= 43 | github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= 44 | github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= 45 | github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= 46 | github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= 47 | github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= 48 | github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= 49 | github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= 50 | github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= 51 | github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= 52 | github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= 53 | github.com/pion/turn/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE= 54 | github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc= 55 | github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= 56 | github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= 57 | github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= 58 | github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 64 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 65 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 66 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 67 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 68 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 69 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 70 | github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 71 | github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= 72 | github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 73 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 74 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 75 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 76 | golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= 77 | golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 78 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 79 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 80 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 81 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 82 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 83 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 84 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 85 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 86 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 87 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 88 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 89 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 90 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 91 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 92 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 93 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 106 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 107 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 108 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 109 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 110 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 111 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 112 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 113 | golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 114 | golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 115 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 116 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 117 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 118 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 119 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 120 | golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 121 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 122 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 123 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 124 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 125 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 126 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 129 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 130 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 132 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Broadcast Box 2 | 3 | [![License][license-image]][license-url] 4 | [![Discord][discord-image]][discord-invite-url] 5 | 6 | - [What is Broadcast Box](#what-is-broadcast-box) 7 | - [Using](#using) 8 | - [Broadcasting](#broadcasting) 9 | - [Broadcasting (GStreamer, CLI)](#broadcasting-gstreamer-cli) 10 | - [Playback](#playback) 11 | - [Getting Started](#getting-started) 12 | - [Configuring](#configuring) 13 | - [Building From Source](#building-from-source) 14 | - [Frontend](#frontend) 15 | - [Backend](#backend) 16 | - [Docker](#docker) 17 | - [Docker Compose](#docker-compose) 18 | - [Environment variables](#environment-variables) 19 | - [Authentication and Logging](#authentication-and-logging) 20 | - [Network Test on Start](#network-test-on-start) 21 | - [Design](#design) 22 | 23 | ## What is Broadcast Box 24 | 25 | Broadcast Box lets you broadcast to others in sub-second time. It was designed 26 | to be simple to use and easily modifiable. We wrote Broadcast Box to show off some 27 | of the cutting edge tech that is coming to the broadcast space. 28 | 29 | Want to contribute to the development of Broadcast Box? See [Contributing](./CONTRIBUTING.md). 30 | 31 | ### Sub-second Latency 32 | 33 | Broadcast Box uses WebRTC for broadcast and playback. By using WebRTC instead of 34 | RTMP and HLS you get the fastest experience possible. 35 | 36 | ### Latest in Video Compression 37 | 38 | With WebRTC you get access to the latest in video codecs. With AV1 you can send 39 | the same video quality with a [50%][av1-practical-use-case] reduction in bandwidth required. 40 | 41 | [av1-practical-use-case]: https://engineering.fb.com/2018/04/10/video-engineering/av1-beats-x264-and-libvpx-vp9-in-practical-use-case/ 42 | 43 | ### Broadcast all angles 44 | 45 | WebRTC allows you to upload multiple video streams in the same session. Now you can 46 | broadcast multiple camera angles, or share interactive video experiences in real time! 47 | 48 | ### Broadcasters provide transcodes 49 | 50 | Transcodes are necessary if you want to provide a good experience to all your users. 51 | Generating them is prohibitively expensive though, WebRTC provides a solution. With WebRTC 52 | users can upload the same video at different quality levels. This 53 | keeps things cheap for the server operator and you still can provide the same 54 | experience. 55 | 56 | ### Broadcasting for all 57 | 58 | WebRTC means anyone can be a broadcaster. With Broadcast Box you could use broadcast software like OBS. 59 | However, another option is publishing directly from your browser! Users just getting started with streaming 60 | don't need to worry about bitrates, codecs anymore. With one press of a button you can go live right from 61 | your browser with Broadcast Box. This makes live-streaming accessible to an entirely new audience. 62 | 63 | ### Peer-to-Peer (if you need it) 64 | 65 | With Broadcast Box you can serve your video without a public IP or forwarding ports! 66 | 67 | Run Broadcast Box on the same machine that you are running OBS, and share your 68 | video with the world! WebRTC comes with P2P technology, so users can broadcast 69 | and playback video without paying for dedicated servers. To start the connection users will 70 | need to be able to connect to the HTTP server. After they have negotiated the session then 71 | NAT traversal begins. 72 | 73 | You could also use P2P to pull other broadcasters into your stream. No special configuration 74 | or servers required anymore to get sub-second co-streams. 75 | 76 | Broadcast Box acts as a [SFU][applied-webrtc-article]. This means that 77 | every client connects to Broadcast Box. No direct connection is established between broadcasters/viewers. 78 | If you want a direct connection between OBS and your browser see [OBS2Browser][obs-2-browser-repo]. 79 | 80 | [applied-webrtc-article]: https://webrtcforthecurious.com/docs/08-applied-webrtc/#selective-forwarding-unit 81 | [obs-2-browser-repo]: https://github.com/Sean-Der/OBS2Browser 82 | 83 | ## Using 84 | 85 | To use Broadcast Box you don't even have to run it locally! A instance of Broadcast Box 86 | is hosted at [b.siobud.com](https://b.siobud.com). If you wish to run it locally skip to [Getting Started](#getting-started) 87 | 88 | ### Broadcasting 89 | 90 | To use Broadcast Box with OBS you must set your output to WebRTC and set a proper URL + Stream Key. 91 | You may use any Stream Key you like. The same stream key is used for broadcasting and playback. 92 | 93 | Go to `Settings -> Stream` and set the following values. 94 | 95 | - Service: WHIP 96 | - Server: 97 | - StreamKey: (Any Stream Key you like) 98 | 99 | Your settings page should look like this: 100 | 101 | ![OBS Stream settings example](./.github/img/streamSettings.png) 102 | 103 | OBS by default will have ~2 seconds of latency. If you want sub-second latency you can configure 104 | this in `Settings -> Output`. Set your encoder to `x264` and set tune to `zerolatency`. Your Output 105 | page will look like this. 106 | 107 | ![OBS Output settings example](./.github/img/outputPage.png) 108 | 109 | When you are ready to broadcast press `Start Streaming` and now time to watch! 110 | 111 | ### Broadcasting (GStreamer, CLI) 112 | 113 | See the example script [here](examples/gstreamer-broadcast.nu). 114 | 115 | Can broadcast gstreamer's test sources, or pulsesrc+v4l2src 116 | 117 | Expects `gstreamer-1.0`, with `good,bad,ugly` plugins and `gst-plugins-rs` 118 | 119 | Use of example scripts: 120 | 121 | ```shell 122 | # testsrcs 123 | ./examples/gstreamer-broadcast.nu http://localhost:8080/api/whip testStream1 124 | # v4l2src 125 | ./examples/gstreamer-broadcast.nu http://localhost:8080/api/whip testStream1 v4l2 126 | ``` 127 | 128 | ### Playback 129 | 130 | If you are broadcasting to the Stream Key `StreamTest` your video will be available at . 131 | 132 | You can also go to the home page and enter `StreamTest`. The following is a screenshot of OBS broadcasting and 133 | the latency of 120 milliseconds observed. 134 | 135 | ![Example have potential latency](./.github/img/broadcastView.png) 136 | 137 | ## Getting Started 138 | 139 | Broadcast Box is made up of two parts. The server is written in Go and is in charge of ingesting and broadcasting WebRTC. The frontend is in react and connects to the Go backend. The Go server can be used to serve the HTML/CSS/JS directly. Use the following instructions to build from source or utilize [Docker](#docker) / [Docker Compose](#docker-compose). 140 | 141 | ### Configuring 142 | 143 | Configurations can be made in [.env.production](./.env.production), although the defaults should get things going. 144 | 145 | ### Building From Source 146 | 147 | #### Frontend 148 | 149 | React dependencies are installed by running `npm install` in the `web` directory and `npm run build` will build the frontend. 150 | 151 | If everything is successful, you should see the following: 152 | 153 | ```console 154 | > broadcast-box@0.1.0 build 155 | > dotenv -e ../.env.production react-scripts build 156 | 157 | Creating an optimized production build... 158 | Compiled successfully. 159 | 160 | File sizes after gzip: 161 | 162 | 53.51 kB build/static/js/main.12067218.js 163 | 2.27 kB build/static/css/main.8738ee38.css 164 | ... 165 | ``` 166 | 167 | #### Backend 168 | 169 | Go dependencies are automatically installed. 170 | 171 | To run the Go server, run `go run .` in the root of this project, you should see the following: 172 | 173 | ```console 174 | 2022/12/11 16:02:14 Loading `.env.production` 175 | 2022/12/11 16:02:14 Running HTTP Server at `:8080` 176 | ``` 177 | 178 | To use Broadcast Box navigate to: `http://:8080`. In your broadcast tool of choice, you will broadcast to `http://:8080/api/whip`. 179 | 180 | ### Docker 181 | 182 | A Docker image is also provided to make it easier to run locally and in production. The arguments you run the Dockerfile with depending on 183 | if you are using it locally or a server. 184 | 185 | If you want to run locally execute `docker run -e UDP_MUX_PORT=8080 -e NAT_1_TO_1_IP=127.0.0.1 -p 8080:8080 -p 8080:8080/udp seaduboi/broadcast-box`. 186 | This will make broadcast-box available on `http://localhost:8080`. The UDPMux is needed because Docker on macOS/Windows runs inside a NAT. 187 | 188 | If you are running on AWS (or other cloud providers) execute. `docker run --net=host -e INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP=yes seaduboi/broadcast-box` 189 | broadcast-box needs to be run in net=host mode. broadcast-box listens on random UDP ports to establish sessions. 190 | 191 | ### Docker Compose 192 | 193 | A Docker Compose is included that uses LetsEncrypt for automated HTTPS. It also includes Watchtower so your instance of Broadcast Box 194 | will be automatically updated every night. If you are running on a VPS/Cloud server this is the quickest/easiest way to get started. 195 | 196 | ```console 197 | export URL=my-server.com 198 | docker-compose up -d 199 | ``` 200 | ## URL Parameters 201 | 202 | The frontend can be configured by passing these URL Parameters. 203 | 204 | - `cinemaMode=true` - Forces the player into cinema mode by adding to end of URL like https://b.siobud.com/myStream?cinemaMode=true 205 | 206 | ## Environment Variables 207 | 208 | The backend can be configured with the following environment variables. 209 | 210 | - `WEBHOOK_URL` - URL for Webhook Backend. Provides authentication and logging 211 | - `DISABLE_STATUS` - Disable the status API 212 | - `DISABLE_FRONTEND` - Disable the serving of frontend. Only REST APIs + WebRTC is enabled. 213 | - `HTTP_ADDRESS` - HTTP Server Address 214 | - `NETWORK_TEST_ON_START` - When "true" on startup Broadcast Box will check network connectivity 215 | 216 | - `ENABLE_HTTP_REDIRECT` - HTTP traffic will be redirect to HTTPS 217 | - `SSL_CERT` - Path to SSL certificate if using Broadcast Box's HTTP Server 218 | - `SSL_KEY` - Path to SSL key if using Broadcast Box's HTTP Server 219 | 220 | - `NAT_1_TO_1_IP` - Announce IPs that don't belong to local machine (like Public IP). delineated by '|' 221 | - `INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP` - Like `NAT_1_TO_1_IP` but autoconfigured 222 | - `INTERFACE_FILTER` - Only use a certain interface for UDP traffic 223 | - `NAT_ICE_CANDIDATE_TYPE` - By default setting a NAT_1_TO_1_IP overrides. Set this to `srflx` to instead append IPs 224 | - `STUN_SERVERS` - List of STUN servers delineated by '|'. Useful if Broadcast Box is running behind a NAT 225 | - `NETWORK_TYPES` - List of network types to use, delineated by '|'. Default is `udp4|udp6`. 226 | - `INCLUDE_LOOPBACK_CANDIDATE` - Also listen for WebRTC traffic on loopback, disabled by default 227 | 228 | - `UDP_MUX_PORT_WHEP` - Like `UDP_MUX_PORT` but only for WHEP traffic 229 | - `UDP_MUX_PORT_WHIP` - Like `UDP_MUX_PORT` but only for WHIP traffic 230 | - `UDP_MUX_PORT` - Serve all UDP traffic via one port. By default Broadcast Box listens on a random port 231 | 232 | - `TCP_MUX_ADDRESS` - If you wish to make WebRTC traffic available via TCP. 233 | - `TCP_MUX_FORCE` - If you wish to make WebRTC traffic only available via TCP. 234 | 235 | - `APPEND_CANDIDATE` - Append candidates to Offer that ICE Agent did not generate. Worse version of `NAT_1_TO_1_IP` 236 | 237 | - `DEBUG_PRINT_OFFER` - Print WebRTC Offers from client to Broadcast Box. Debug things like accepted codecs. 238 | - `DEBUG_PRINT_ANSWER` - Print WebRTC Answers from Broadcast Box to Browser. Debug things like IP/Ports returned to client. 239 | 240 | ## Authentication and Logging 241 | 242 | To prevent random users from streaming to your server, you can set the `WEBHOOK_URL` and validate/process requests in your code. This enables you to separate the authorization between broadcasting (whip) and watching (whep). So you can safely share a watch link without exposing the key used for broadcasting. 243 | 244 | If the request succeeds (meaning the stream key is accepted), broadcast-box redirects the stream to an url given by the external server, otherwise the streaming request is dropped. 245 | 246 | See [here](examples/webhook-server.go). For an example Webhook Server that only allows the stream `broadcastBoxRulez` 247 | 248 | For a more advanced example of a webhook server implementation making use of separating the key for streaming from the key for watching, see the [broadcastbox-webhookserver](https://github.com/chrisingenhaag/broadcastbox-webhookserver) repository. 249 | 250 | 251 | ## Network Test on Start 252 | 253 | When running in Docker Broadcast Box runs a network tests on startup. This tests that WebRTC traffic can be established 254 | against your server. If you server is misconfigured Broadcast Box will not start. 255 | 256 | If the network test is enabled this will be printed on startup 257 | 258 | ```console 259 | NETWORK_TEST_ON_START is enabled. If the test fails Broadcast Box will exit. 260 | See the README.md for how to debug or disable NETWORK_TEST_ON_START 261 | ``` 262 | 263 | If the test passed you will see 264 | 265 | ```console 266 | Network Test passed. 267 | Have fun using Broadcast Box 268 | ``` 269 | 270 | If the test failed you will see the following. The middle sentence will change depending on the error. 271 | 272 | ```console 273 | Network Test failed. 274 | Network Test client reported nothing in 30 seconds 275 | Please see the README and join Discord for help 276 | ``` 277 | 278 | [Join the Discord][discord-invite-url] and we are ready to help! To debug check the following. 279 | 280 | - Have you allowed UDP traffic? 281 | - Do you have any restrictions on ports? 282 | - Is your server publicly accessible? 283 | 284 | If you wish to disable the test set the environment variable `NETWORK_TEST_ON_START` to false. 285 | 286 | ## Design 287 | 288 | The backend exposes three endpoints (the status page is optional, if hosting locally). 289 | 290 | - `/api/whip` - Start a WHIP Session. WHIP broadcasts video via WebRTC. 291 | - `/api/whep` - Start a WHEP Session. WHEP is video playback via WebRTC. 292 | - `/api/status` - Status of the all active WHIP streams 293 | 294 | [license-image]: https://img.shields.io/badge/License-MIT-yellow.svg 295 | [license-url]: https://opensource.org/licenses/MIT 296 | [discord-image]: https://img.shields.io/discord/1162823780708651018?logo=discord 297 | [discord-invite-url]: https://discord.gg/An5jjhNUE3 298 | -------------------------------------------------------------------------------- /internal/webrtc/webrtc.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "slices" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "sync/atomic" 17 | "time" 18 | 19 | "github.com/pion/dtls/v3/pkg/crypto/elliptic" 20 | "github.com/pion/ice/v3" 21 | "github.com/pion/interceptor" 22 | "github.com/pion/webrtc/v4" 23 | ) 24 | 25 | const ( 26 | videoTrackLabelDefault = "default" 27 | 28 | videoTrackCodecH264 videoTrackCodec = iota + 1 29 | videoTrackCodecVP8 30 | videoTrackCodecVP9 31 | videoTrackCodecAV1 32 | videoTrackCodecH265 33 | ) 34 | 35 | type ( 36 | stream struct { 37 | // Does this stream have a publisher? 38 | // If stream was created by a WHEP request hasWHIPClient == false 39 | hasWHIPClient atomic.Bool 40 | sessionId string 41 | 42 | firstSeenEpoch uint64 43 | 44 | videoTracks []*videoTrack 45 | 46 | audioTrack *webrtc.TrackLocalStaticRTP 47 | audioPacketsReceived atomic.Uint64 48 | 49 | pliChan chan any 50 | 51 | whipActiveContext context.Context 52 | whipActiveContextCancel func() 53 | 54 | whepSessionsLock sync.RWMutex 55 | whepSessions map[string]*whepSession 56 | } 57 | 58 | videoTrack struct { 59 | sessionId string 60 | rid string 61 | packetsReceived atomic.Uint64 62 | lastKeyFrameSeen atomic.Value 63 | } 64 | 65 | videoTrackCodec int 66 | ) 67 | 68 | var ( 69 | streamMap map[string]*stream 70 | streamMapLock sync.Mutex 71 | apiWhip, apiWhep *webrtc.API 72 | 73 | // nolint 74 | videoRTCPFeedback = []webrtc.RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}, {"nack", "pli"}} 75 | ) 76 | 77 | func getVideoTrackCodec(in string) videoTrackCodec { 78 | downcased := strings.ToLower(in) 79 | switch { 80 | case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeH264)): 81 | return videoTrackCodecH264 82 | case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeVP8)): 83 | return videoTrackCodecVP8 84 | case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeVP9)): 85 | return videoTrackCodecVP9 86 | case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeAV1)): 87 | return videoTrackCodecAV1 88 | case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeH265)): 89 | return videoTrackCodecH265 90 | } 91 | 92 | return 0 93 | } 94 | 95 | func getStream(streamKey string, whipSessionId string) (*stream, error) { 96 | foundStream, ok := streamMap[streamKey] 97 | if !ok { 98 | audioTrack, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | whipActiveContext, whipActiveContextCancel := context.WithCancel(context.Background()) 104 | 105 | foundStream = &stream{ 106 | audioTrack: audioTrack, 107 | pliChan: make(chan any, 50), 108 | whepSessions: map[string]*whepSession{}, 109 | whipActiveContext: whipActiveContext, 110 | whipActiveContextCancel: whipActiveContextCancel, 111 | firstSeenEpoch: uint64(time.Now().Unix()), 112 | } 113 | streamMap[streamKey] = foundStream 114 | } 115 | 116 | if whipSessionId != "" { 117 | foundStream.hasWHIPClient.Store(true) 118 | foundStream.sessionId = whipSessionId 119 | } 120 | 121 | return foundStream, nil 122 | } 123 | 124 | func peerConnectionDisconnected(forWHIP bool, streamKey string, sessionId string) { 125 | streamMapLock.Lock() 126 | defer streamMapLock.Unlock() 127 | 128 | stream, ok := streamMap[streamKey] 129 | if !ok { 130 | return 131 | } 132 | 133 | stream.whepSessionsLock.Lock() 134 | defer stream.whepSessionsLock.Unlock() 135 | 136 | if !forWHIP { 137 | delete(stream.whepSessions, sessionId) 138 | } else { 139 | stream.videoTracks = slices.DeleteFunc(stream.videoTracks, func(v *videoTrack) bool { 140 | return v.sessionId == sessionId 141 | }) 142 | 143 | // A PeerConnection for a old WHIP session has gone to disconnected 144 | // closed. Cleanup the state associated with that session, but 145 | // don't modify the current session 146 | if stream.sessionId != sessionId { 147 | return 148 | } 149 | stream.hasWHIPClient.Store(false) 150 | } 151 | 152 | // Only delete stream if all WHEP Sessions are gone and have no WHIP Client 153 | if len(stream.whepSessions) != 0 || stream.hasWHIPClient.Load() { 154 | return 155 | } 156 | 157 | stream.whipActiveContextCancel() 158 | delete(streamMap, streamKey) 159 | } 160 | 161 | func addTrack(stream *stream, rid, sessionId string) (*videoTrack, error) { 162 | streamMapLock.Lock() 163 | defer streamMapLock.Unlock() 164 | 165 | for i := range stream.videoTracks { 166 | if rid == stream.videoTracks[i].rid && sessionId == stream.videoTracks[i].sessionId { 167 | return stream.videoTracks[i], nil 168 | } 169 | } 170 | 171 | t := &videoTrack{rid: rid, sessionId: sessionId} 172 | t.lastKeyFrameSeen.Store(time.Time{}) 173 | stream.videoTracks = append(stream.videoTracks, t) 174 | return t, nil 175 | } 176 | 177 | func getPublicIP() string { 178 | req, err := http.Get("http://ip-api.com/json/") 179 | if err != nil { 180 | log.Fatal(err) 181 | } 182 | defer func() { 183 | if closeErr := req.Body.Close(); closeErr != nil { 184 | log.Fatal(err) 185 | } 186 | }() 187 | 188 | body, err := io.ReadAll(req.Body) 189 | if err != nil { 190 | log.Fatal(err) 191 | } 192 | 193 | ip := struct { 194 | Query string 195 | }{} 196 | if err = json.Unmarshal(body, &ip); err != nil { 197 | log.Fatal(err) 198 | } 199 | 200 | if ip.Query == "" { 201 | log.Fatal("Query entry was not populated") 202 | } 203 | 204 | return ip.Query 205 | } 206 | 207 | func createSettingEngine(isWHIP bool, udpMuxCache map[int]*ice.MultiUDPMuxDefault, tcpMuxCache map[string]ice.TCPMux) (settingEngine webrtc.SettingEngine) { 208 | var ( 209 | NAT1To1IPs []string 210 | networkTypes []webrtc.NetworkType 211 | udpMuxPort int 212 | udpMuxOpts []ice.UDPMuxFromPortOption 213 | err error 214 | ) 215 | 216 | if os.Getenv("NETWORK_TYPES") != "" { 217 | for _, networkTypeStr := range strings.Split(os.Getenv("NETWORK_TYPES"), "|") { 218 | networkType, err := webrtc.NewNetworkType(networkTypeStr) 219 | if err != nil { 220 | log.Fatal(err) 221 | } 222 | networkTypes = append(networkTypes, networkType) 223 | } 224 | } else { 225 | networkTypes = append(networkTypes, webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6) 226 | } 227 | 228 | if os.Getenv("INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP") != "" { 229 | NAT1To1IPs = append(NAT1To1IPs, getPublicIP()) 230 | } 231 | 232 | if os.Getenv("NAT_1_TO_1_IP") != "" { 233 | NAT1To1IPs = append(NAT1To1IPs, strings.Split(os.Getenv("NAT_1_TO_1_IP"), "|")...) 234 | } 235 | 236 | natICECandidateType := webrtc.ICECandidateTypeHost 237 | if os.Getenv("NAT_ICE_CANDIDATE_TYPE") == "srflx" { 238 | natICECandidateType = webrtc.ICECandidateTypeSrflx 239 | } 240 | 241 | if len(NAT1To1IPs) != 0 { 242 | settingEngine.SetNAT1To1IPs(NAT1To1IPs, natICECandidateType) 243 | } 244 | 245 | if os.Getenv("INTERFACE_FILTER") != "" { 246 | interfaceFilter := func(i string) bool { 247 | return i == os.Getenv("INTERFACE_FILTER") 248 | } 249 | 250 | settingEngine.SetInterfaceFilter(interfaceFilter) 251 | udpMuxOpts = append(udpMuxOpts, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter)) 252 | } 253 | 254 | if isWHIP && os.Getenv("UDP_MUX_PORT_WHIP") != "" { 255 | if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT_WHIP")); err != nil { 256 | log.Fatal(err) 257 | } 258 | } else if !isWHIP && os.Getenv("UDP_MUX_PORT_WHEP") != "" { 259 | if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT_WHEP")); err != nil { 260 | log.Fatal(err) 261 | } 262 | } else if os.Getenv("UDP_MUX_PORT") != "" { 263 | if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT")); err != nil { 264 | log.Fatal(err) 265 | } 266 | } 267 | 268 | if udpMuxPort != 0 { 269 | udpMux, ok := udpMuxCache[udpMuxPort] 270 | if !ok { 271 | if udpMux, err = ice.NewMultiUDPMuxFromPort(udpMuxPort, udpMuxOpts...); err != nil { 272 | log.Fatal(err) 273 | } 274 | udpMuxCache[udpMuxPort] = udpMux 275 | } 276 | 277 | settingEngine.SetICEUDPMux(udpMux) 278 | } 279 | 280 | if os.Getenv("TCP_MUX_ADDRESS") != "" { 281 | tcpMux, ok := tcpMuxCache[os.Getenv("TCP_MUX_ADDRESS")] 282 | if !ok { 283 | tcpAddr, err := net.ResolveTCPAddr("tcp", os.Getenv("TCP_MUX_ADDRESS")) 284 | if err != nil { 285 | log.Fatal(err) 286 | } 287 | 288 | tcpListener, err := net.ListenTCP("tcp", tcpAddr) 289 | if err != nil { 290 | log.Fatal(err) 291 | } 292 | 293 | tcpMux = webrtc.NewICETCPMux(nil, tcpListener, 8) 294 | tcpMuxCache[os.Getenv("TCP_MUX_ADDRESS")] = tcpMux 295 | } 296 | settingEngine.SetICETCPMux(tcpMux) 297 | 298 | if os.Getenv("TCP_MUX_FORCE") != "" { 299 | networkTypes = []webrtc.NetworkType{webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6} 300 | } else { 301 | networkTypes = append(networkTypes, webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6) 302 | } 303 | } 304 | 305 | settingEngine.SetDTLSEllipticCurves(elliptic.X25519, elliptic.P384, elliptic.P256) 306 | settingEngine.SetNetworkTypes(networkTypes) 307 | settingEngine.DisableSRTCPReplayProtection(true) 308 | settingEngine.DisableSRTPReplayProtection(true) 309 | settingEngine.SetIncludeLoopbackCandidate(os.Getenv("INCLUDE_LOOPBACK_CANDIDATE") != "") 310 | 311 | return 312 | } 313 | 314 | func PopulateMediaEngine(m *webrtc.MediaEngine) error { 315 | for _, codec := range []webrtc.RTPCodecParameters{ 316 | { 317 | // nolint 318 | RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", nil}, 319 | PayloadType: 111, 320 | }, 321 | } { 322 | if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil { 323 | return err 324 | } 325 | } 326 | 327 | for _, codecDetails := range []struct { 328 | payloadType uint8 329 | mimeType string 330 | sdpFmtpLine string 331 | }{ 332 | {102, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f"}, 333 | {104, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f"}, 334 | {106, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"}, 335 | {108, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f"}, 336 | {39, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f"}, 337 | {45, webrtc.MimeTypeAV1, ""}, 338 | {98, webrtc.MimeTypeVP9, "profile-id=0"}, 339 | {100, webrtc.MimeTypeVP9, "profile-id=2"}, 340 | {113, webrtc.MimeTypeH265, "level-id=93;profile-id=1;tier-flag=0;tx-mode=SRST"}, 341 | } { 342 | if err := m.RegisterCodec(webrtc.RTPCodecParameters{ 343 | RTPCodecCapability: webrtc.RTPCodecCapability{ 344 | MimeType: codecDetails.mimeType, 345 | ClockRate: 90000, 346 | Channels: 0, 347 | SDPFmtpLine: codecDetails.sdpFmtpLine, 348 | RTCPFeedback: videoRTCPFeedback, 349 | }, 350 | PayloadType: webrtc.PayloadType(codecDetails.payloadType), 351 | }, webrtc.RTPCodecTypeVideo); err != nil { 352 | return err 353 | } 354 | 355 | if err := m.RegisterCodec(webrtc.RTPCodecParameters{ 356 | RTPCodecCapability: webrtc.RTPCodecCapability{ 357 | MimeType: "video/rtx", 358 | ClockRate: 90000, 359 | Channels: 0, 360 | SDPFmtpLine: fmt.Sprintf("apt=%d", codecDetails.payloadType), 361 | RTCPFeedback: nil, 362 | }, 363 | PayloadType: webrtc.PayloadType(codecDetails.payloadType + 1), 364 | }, webrtc.RTPCodecTypeVideo); err != nil { 365 | return err 366 | } 367 | } 368 | 369 | return nil 370 | } 371 | 372 | func newPeerConnection(api *webrtc.API) (*webrtc.PeerConnection, error) { 373 | cfg := webrtc.Configuration{} 374 | 375 | if stunServers := os.Getenv("STUN_SERVERS"); stunServers != "" { 376 | for _, stunServer := range strings.Split(stunServers, "|") { 377 | cfg.ICEServers = append(cfg.ICEServers, webrtc.ICEServer{ 378 | URLs: []string{"stun:" + stunServer}, 379 | }) 380 | } 381 | } 382 | 383 | return api.NewPeerConnection(cfg) 384 | } 385 | 386 | func appendAnswer(in string) string { 387 | if extraCandidate := os.Getenv("APPEND_CANDIDATE"); extraCandidate != "" { 388 | index := strings.Index(in, "a=end-of-candidates") 389 | in = in[:index] + extraCandidate + in[index:] 390 | } 391 | 392 | return in 393 | } 394 | 395 | func maybePrintOfferAnswer(sdp string, isOffer bool) string { 396 | if os.Getenv("DEBUG_PRINT_OFFER") != "" && isOffer { 397 | fmt.Println(sdp) 398 | } 399 | 400 | if os.Getenv("DEBUG_PRINT_ANSWER") != "" && !isOffer { 401 | fmt.Println(sdp) 402 | } 403 | 404 | return sdp 405 | } 406 | 407 | func Configure() { 408 | streamMap = map[string]*stream{} 409 | 410 | mediaEngine := &webrtc.MediaEngine{} 411 | if err := PopulateMediaEngine(mediaEngine); err != nil { 412 | panic(err) 413 | } 414 | 415 | interceptorRegistry := &interceptor.Registry{} 416 | if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { 417 | log.Fatal(err) 418 | } 419 | 420 | udpMuxCache := map[int]*ice.MultiUDPMuxDefault{} 421 | tcpMuxCache := map[string]ice.TCPMux{} 422 | 423 | apiWhip = webrtc.NewAPI( 424 | webrtc.WithMediaEngine(mediaEngine), 425 | webrtc.WithInterceptorRegistry(interceptorRegistry), 426 | webrtc.WithSettingEngine(createSettingEngine(true, udpMuxCache, tcpMuxCache)), 427 | ) 428 | 429 | apiWhep = webrtc.NewAPI( 430 | webrtc.WithMediaEngine(mediaEngine), 431 | webrtc.WithInterceptorRegistry(interceptorRegistry), 432 | webrtc.WithSettingEngine(createSettingEngine(false, udpMuxCache, tcpMuxCache)), 433 | ) 434 | } 435 | 436 | type StreamStatusVideo struct { 437 | RID string `json:"rid"` 438 | PacketsReceived uint64 `json:"packetsReceived"` 439 | LastKeyFrameSeen time.Time `json:"lastKeyFrameSeen"` 440 | } 441 | 442 | type StreamStatus struct { 443 | StreamKey string `json:"streamKey"` 444 | FirstSeenEpoch uint64 `json:"firstSeenEpoch"` 445 | AudioPacketsReceived uint64 `json:"audioPacketsReceived"` 446 | VideoStreams []StreamStatusVideo `json:"videoStreams"` 447 | WHEPSessions []whepSessionStatus `json:"whepSessions"` 448 | } 449 | 450 | type whepSessionStatus struct { 451 | ID string `json:"id"` 452 | CurrentLayer string `json:"currentLayer"` 453 | SequenceNumber uint16 `json:"sequenceNumber"` 454 | Timestamp uint32 `json:"timestamp"` 455 | PacketsWritten uint64 `json:"packetsWritten"` 456 | } 457 | 458 | func GetStreamStatuses() []StreamStatus { 459 | streamMapLock.Lock() 460 | defer streamMapLock.Unlock() 461 | 462 | out := []StreamStatus{} 463 | 464 | for streamKey, stream := range streamMap { 465 | whepSessions := []whepSessionStatus{} 466 | stream.whepSessionsLock.Lock() 467 | for id, whepSession := range stream.whepSessions { 468 | currentLayer, ok := whepSession.currentLayer.Load().(string) 469 | if !ok { 470 | continue 471 | } 472 | 473 | whepSessions = append(whepSessions, whepSessionStatus{ 474 | ID: id, 475 | CurrentLayer: currentLayer, 476 | SequenceNumber: whepSession.sequenceNumber, 477 | Timestamp: whepSession.timestamp, 478 | PacketsWritten: whepSession.packetsWritten, 479 | }) 480 | } 481 | stream.whepSessionsLock.Unlock() 482 | 483 | streamStatusVideo := []StreamStatusVideo{} 484 | for _, videoTrack := range stream.videoTracks { 485 | var lastKeyFrameSeen time.Time 486 | if v, ok := videoTrack.lastKeyFrameSeen.Load().(time.Time); ok { 487 | lastKeyFrameSeen = v 488 | } 489 | 490 | streamStatusVideo = append(streamStatusVideo, StreamStatusVideo{ 491 | RID: videoTrack.rid, 492 | PacketsReceived: videoTrack.packetsReceived.Load(), 493 | LastKeyFrameSeen: lastKeyFrameSeen, 494 | }) 495 | } 496 | 497 | out = append(out, StreamStatus{ 498 | StreamKey: streamKey, 499 | FirstSeenEpoch: stream.firstSeenEpoch, 500 | AudioPacketsReceived: stream.audioPacketsReceived.Load(), 501 | VideoStreams: streamStatusVideo, 502 | WHEPSessions: whepSessions, 503 | }) 504 | } 505 | 506 | return out 507 | } 508 | --------------------------------------------------------------------------------