From 5c4284d552eb2e352e1ee40638348e082e19c6d5 Mon Sep 17 00:00:00 2001
From: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
Date: Sun, 19 Apr 2026 18:08:17 +0000
Subject: [PATCH 1/8] feat: add webrtc tooling
---
apps/web/src/lib/utils/mediamtx/webrtc.ts | 498 ++++++++++++++++++++++
dev/docker-compose.yml | 1 +
dev/mediamtx.yml | 2 +
3 files changed, 501 insertions(+)
create mode 100644 apps/web/src/lib/utils/mediamtx/webrtc.ts
diff --git a/apps/web/src/lib/utils/mediamtx/webrtc.ts b/apps/web/src/lib/utils/mediamtx/webrtc.ts
new file mode 100644
index 0000000..98505a6
--- /dev/null
+++ b/apps/web/src/lib/utils/mediamtx/webrtc.ts
@@ -0,0 +1,498 @@
+// based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js
+// modified by codex to typescript
+type OnError = (err: string) => void;
+type OnConnected = () => void;
+
+type PublisherState = 'running' | 'restarting' | 'closed';
+
+type PublisherConfig = {
+ url: string;
+ user?: string;
+ pass?: string;
+ token?: string;
+ stream: MediaStream;
+ videoCodec: string;
+ videoBitrate: number;
+ audioCodec: string;
+ audioBitrate: number;
+ audioVoice: boolean;
+ onError?: OnError;
+ onConnected?: OnConnected;
+};
+
+type OfferData = {
+ iceUfrag: string;
+ icePwd: string;
+ medias: string[];
+};
+
+type ParsedIceServer = RTCIceServer & {
+ credentialType?: 'password';
+};
+
+interface Window {
+ MediaMTXWebRTCPublisher: typeof MediaMTXWebRTCPublisher;
+}
+
+/** WebRTC/WHIP publisher. */
+class MediaMTXWebRTCPublisher {
+ private readonly retryPause = 2000;
+ private readonly conf: PublisherConfig;
+ private state: PublisherState = 'running';
+ private restartTimeout: number | null = null;
+ private pc: RTCPeerConnection | null = null;
+ private offerData: OfferData | null = null;
+ private sessionUrl: string | null = null;
+ private queuedCandidates: RTCIceCandidate[] = [];
+
+ constructor(conf: PublisherConfig) {
+ this.conf = conf;
+ this.start();
+ }
+
+ close = (): void => {
+ this.state = 'closed';
+
+ if (this.pc !== null) {
+ this.pc.close();
+ }
+
+ if (this.restartTimeout !== null) {
+ window.clearTimeout(this.restartTimeout);
+ }
+ };
+
+ static #unquoteCredential(value: string): string {
+ return JSON.parse(`"${value}"`) as string;
+ }
+
+ static #linkToIceServers(links: string | null): ParsedIceServer[] {
+ if (links === null) {
+ return [];
+ }
+
+ return links.split(', ').flatMap((link) => {
+ const match = link.match(
+ /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i
+ );
+
+ if (!match) {
+ return [];
+ }
+
+ const iceServer: ParsedIceServer = {
+ urls: [match[1]],
+ };
+
+ if (match[3] !== undefined && match[4] !== undefined) {
+ iceServer.username = this.#unquoteCredential(match[3]);
+ iceServer.credential = this.#unquoteCredential(match[4]);
+ iceServer.credentialType = 'password';
+ }
+
+ return [iceServer];
+ });
+ }
+
+ static #parseOffer(offer: string): OfferData {
+ const parsedOffer: OfferData = {
+ iceUfrag: '',
+ icePwd: '',
+ medias: [],
+ };
+
+ for (const line of offer.split('\r\n')) {
+ if (line.startsWith('m=')) {
+ parsedOffer.medias.push(line.slice('m='.length));
+ } else if (parsedOffer.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
+ parsedOffer.iceUfrag = line.slice('a=ice-ufrag:'.length);
+ } else if (parsedOffer.icePwd === '' && line.startsWith('a=ice-pwd:')) {
+ parsedOffer.icePwd = line.slice('a=ice-pwd:'.length);
+ }
+ }
+
+ return parsedOffer;
+ }
+
+ static #generateSdpFragment(
+ offerData: OfferData,
+ candidates: RTCIceCandidate[]
+ ): string {
+ const candidatesByMedia: Record = {};
+
+ for (const candidate of candidates) {
+ const mid = candidate.sdpMLineIndex;
+ if (mid === null) {
+ continue;
+ }
+
+ if (candidatesByMedia[mid] === undefined) {
+ candidatesByMedia[mid] = [];
+ }
+ candidatesByMedia[mid].push(candidate);
+ }
+
+ let fragment = `a=ice-ufrag:${offerData.iceUfrag}\r\n`
+ + `a=ice-pwd:${offerData.icePwd}\r\n`;
+
+ let mid = 0;
+
+ for (const media of offerData.medias) {
+ if (candidatesByMedia[mid] !== undefined) {
+ fragment += `m=${media}\r\n`
+ + `a=mid:${mid}\r\n`;
+
+ for (const candidate of candidatesByMedia[mid]) {
+ fragment += `a=${candidate.candidate}\r\n`;
+ }
+ }
+ mid++;
+ }
+
+ return fragment;
+ }
+
+ static #setCodec(section: string, codec: string): string {
+ const normalizedCodec = codec.toLowerCase();
+ const lines = section.split('\r\n');
+ const filteredLines: string[] = [];
+ const payloadFormats: string[] = [];
+
+ for (const line of lines) {
+ if (!line.startsWith('a=rtpmap:')) {
+ filteredLines.push(line);
+ } else if (line.toLowerCase().includes(normalizedCodec)) {
+ payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
+ filteredLines.push(line);
+ }
+ }
+
+ const rewrittenLines: string[] = [];
+ let firstLine = true;
+
+ for (const line of filteredLines) {
+ if (firstLine) {
+ firstLine = false;
+ rewrittenLines.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' '));
+ } else if (line.startsWith('a=fmtp:')) {
+ if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
+ rewrittenLines.push(line);
+ }
+ } else if (line.startsWith('a=rtcp-fb:')) {
+ if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) {
+ rewrittenLines.push(line);
+ }
+ } else {
+ rewrittenLines.push(line);
+ }
+ }
+
+ return rewrittenLines.join('\r\n');
+ }
+
+ static #setVideoBitrate(section: string, bitrate: number): string {
+ let lines = section.split('\r\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith('c=')) {
+ lines = [
+ ...lines.slice(0, i + 1),
+ `b=TIAS:${(bitrate * 1024).toString()}`,
+ ...lines.slice(i + 1),
+ ];
+ break;
+ }
+ }
+
+ return lines.join('\r\n');
+ }
+
+ static #setAudioBitrate(section: string, bitrate: number, voice: boolean): string {
+ let opusPayloadFormat = '';
+ const lines = section.split('\r\n');
+
+ for (const line of lines) {
+ if (line.startsWith('a=rtpmap:') && line.toLowerCase().includes('opus/')) {
+ opusPayloadFormat = line.slice('a=rtpmap:'.length).split(' ')[0];
+ break;
+ }
+ }
+
+ if (opusPayloadFormat === '') {
+ return section;
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) {
+ if (voice) {
+ lines[i] =
+ `a=fmtp:${opusPayloadFormat} minptime=10;useinbandfec=1;maxaveragebitrate=${(bitrate * 1024).toString()}`;
+ } else {
+ lines[i] =
+ `a=fmtp:${opusPayloadFormat} maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=${(bitrate * 1024).toString()}`;
+ }
+ }
+ }
+
+ return lines.join('\r\n');
+ }
+
+ static #editOffer(
+ sdp: string,
+ videoCodec: string,
+ audioCodec: string,
+ audioBitrate: number,
+ audioVoice: boolean
+ ): string {
+ const sections = sdp.split('m=');
+
+ for (let i = 0; i < sections.length; i++) {
+ if (sections[i].startsWith('video')) {
+ sections[i] = this.#setCodec(sections[i], videoCodec);
+ } else if (sections[i].startsWith('audio')) {
+ sections[i] = this.#setAudioBitrate(
+ this.#setCodec(sections[i], audioCodec),
+ audioBitrate,
+ audioVoice
+ );
+ }
+ }
+
+ return sections.join('m=');
+ }
+
+ static #editAnswer(sdp: string, videoBitrate: number): string {
+ const sections = sdp.split('m=');
+
+ for (let i = 0; i < sections.length; i++) {
+ if (sections[i].startsWith('video')) {
+ sections[i] = this.#setVideoBitrate(sections[i], videoBitrate);
+ }
+ }
+
+ return sections.join('m=');
+ }
+
+ private async start(): Promise {
+ try {
+ const iceServers = await this.requestIceServers();
+ const offer = await this.setupPeerConnection(iceServers);
+ const answer = await this.sendOffer(offer);
+ await this.setAnswer(answer);
+ } catch (error) {
+ this.handleError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ private handleError(err: string): void {
+ if (this.state === 'running') {
+ if (this.pc !== null) {
+ this.pc.close();
+ this.pc = null;
+ }
+
+ this.offerData = null;
+
+ if (this.sessionUrl !== null) {
+ void fetch(this.sessionUrl, {
+ method: 'DELETE',
+ });
+ this.sessionUrl = null;
+ }
+
+ this.queuedCandidates = [];
+ this.state = 'restarting';
+
+ this.restartTimeout = window.setTimeout(() => {
+ this.restartTimeout = null;
+ this.state = 'running';
+ void this.start();
+ }, this.retryPause);
+
+ this.conf.onError?.(`${err}, retrying in some seconds`);
+ }
+ }
+
+ private authHeader(): HeadersInit {
+ if (this.conf.user !== undefined && this.conf.user !== '') {
+ const credentials = btoa(`${this.conf.user}:${this.conf.pass ?? ''}`);
+ return { Authorization: `Basic ${credentials}` };
+ }
+ if (this.conf.token !== undefined && this.conf.token !== '') {
+ return { Authorization: `Bearer ${this.conf.token}` };
+ }
+ return {};
+ }
+
+ private async requestIceServers(): Promise {
+ const response = await fetch(this.conf.url, {
+ method: 'OPTIONS',
+ headers: {
+ ...this.authHeader(),
+ },
+ });
+
+ return MediaMTXWebRTCPublisher.#linkToIceServers(response.headers.get('Link'));
+ }
+
+ private async setupPeerConnection(iceServers: RTCIceServer[]): Promise {
+ if (this.state !== 'running') {
+ throw new Error('closed');
+ }
+
+ this.pc = new RTCPeerConnection({
+ iceServers,
+ });
+
+ this.pc.onicecandidate = (event) => this.onLocalCandidate(event);
+ this.pc.onconnectionstatechange = () => this.onConnectionState();
+
+ this.conf.stream.getTracks().forEach((track) => {
+ this.pc?.addTrack(track, this.conf.stream);
+ });
+
+ const offer = await this.pc.createOffer();
+ if (!offer.sdp) {
+ throw new Error('missing offer SDP');
+ }
+
+ this.offerData = MediaMTXWebRTCPublisher.#parseOffer(offer.sdp);
+ await this.pc.setLocalDescription(offer);
+
+ return offer.sdp;
+ }
+
+ private async sendOffer(offer: string): Promise {
+ if (this.state !== 'running') {
+ throw new Error('closed');
+ }
+
+ const editedOffer = MediaMTXWebRTCPublisher.#editOffer(
+ offer,
+ this.conf.videoCodec,
+ this.conf.audioCodec,
+ this.conf.audioBitrate,
+ this.conf.audioVoice
+ );
+
+ const response = await fetch(this.conf.url, {
+ method: 'POST',
+ headers: {
+ ...this.authHeader(),
+ 'Content-Type': 'application/sdp',
+ },
+ body: editedOffer,
+ });
+
+ switch (response.status) {
+ case 201:
+ break;
+ case 400: {
+ const errorBody = (await response.json()) as { error?: string };
+ throw new Error(errorBody.error ?? 'bad request');
+ }
+ default:
+ throw new Error(`bad status code ${response.status}`);
+ }
+
+ const location = response.headers.get('location');
+ if (!location) {
+ throw new Error('missing session location');
+ }
+
+ this.sessionUrl = new URL(location, this.conf.url).toString();
+
+ return response.text();
+ }
+
+ private async setAnswer(answer: string): Promise {
+ if (this.state !== 'running') {
+ throw new Error('closed');
+ }
+
+ const peerConnection = this.pc;
+ if (peerConnection === null) {
+ throw new Error('missing peer connection');
+ }
+
+ const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer(
+ answer,
+ this.conf.videoBitrate
+ );
+
+ await peerConnection.setRemoteDescription(
+ new RTCSessionDescription({
+ type: 'answer',
+ sdp: editedAnswer,
+ })
+ );
+
+ if (this.state !== 'running') {
+ return;
+ }
+
+ if (this.queuedCandidates.length !== 0) {
+ this.sendLocalCandidates(this.queuedCandidates);
+ this.queuedCandidates = [];
+ }
+ }
+
+ private onLocalCandidate(event: RTCPeerConnectionIceEvent): void {
+ if (this.state !== 'running') {
+ return;
+ }
+
+ if (event.candidate !== null) {
+ if (this.sessionUrl === null) {
+ this.queuedCandidates.push(event.candidate);
+ } else {
+ this.sendLocalCandidates([event.candidate]);
+ }
+ }
+ }
+
+ private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
+ if (this.sessionUrl === null || this.offerData === null) {
+ return;
+ }
+
+ void fetch(this.sessionUrl, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/trickle-ice-sdpfrag',
+ 'If-Match': '*',
+ },
+ body: MediaMTXWebRTCPublisher.#generateSdpFragment(this.offerData, candidates),
+ })
+ .then((response) => {
+ switch (response.status) {
+ case 204:
+ break;
+ case 404:
+ throw new Error('stream not found');
+ default:
+ throw new Error(`bad status code ${response.status}`);
+ }
+ })
+ .catch((error) => {
+ this.handleError(error instanceof Error ? error.message : String(error));
+ });
+ }
+
+ private onConnectionState(): void {
+ if (this.state !== 'running' || this.pc === null) {
+ return;
+ }
+
+ if (
+ this.pc.connectionState === 'failed'
+ || this.pc.connectionState === 'closed'
+ ) {
+ this.handleError('peer connection closed');
+ } else if (this.pc.connectionState === 'connected') {
+ this.conf.onConnected?.();
+ }
+ }
+}
+
+window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;
diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml
index 87f8588..db90f5d 100644
--- a/dev/docker-compose.yml
+++ b/dev/docker-compose.yml
@@ -28,6 +28,7 @@ services:
ports:
- 8890:8890/udp
- 8891:8888
+ - 8889:8889
- 9997:9997
- 9998:9998
volumes:
diff --git a/dev/mediamtx.yml b/dev/mediamtx.yml
index 4a8a231..a9bb70c 100644
--- a/dev/mediamtx.yml
+++ b/dev/mediamtx.yml
@@ -11,6 +11,8 @@ hlsSegmentDuration: 2s
hlsPartDuration: 500ms
hlsSegmentCount: 10
+webrtc: yes
+
authMethod: http
authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish
From 0add39f8e1905602834823747b8baec433313314 Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Tue, 21 Apr 2026 22:06:19 +0200
Subject: [PATCH 2/8] feat(bs): initial browser streaming impl
---
.../(protected)/api/mediamtx/publish/route.ts | 5 +-
.../src/app/(ui)/(protected)/stream/page.tsx | 119 ++++++++++++++++++
apps/web/src/lib/utils/mediamtx/webrtc.ts | 32 +++--
apps/web/src/lib/workers/worker/thumbnails.ts | 3 +-
4 files changed, 143 insertions(+), 16 deletions(-)
create mode 100644 apps/web/src/app/(ui)/(protected)/stream/page.tsx
diff --git a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
index 7defa24..e9b4083 100644
--- a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
+++ b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
action = parsedAction;
protocol = parsedProtocol;
- if (parsedAction === 'publish' && parsedProtocol === 'srt') {
+ if (parsedAction === 'publish' && (parsedProtocol === 'srt' || parsedProtocol === 'webrtc')) {
const channelKey = await redis.get(`streamKey:${path}`);
if (channelKey) {
@@ -69,7 +69,8 @@ export async function POST(request: NextRequest) {
return finish('youre in yay', 200, 'authorized_publish');
}
- } else if (parsedAction === 'read' && parsedProtocol === 'hls') {
+ }
+ if (parsedAction === 'read' && parsedProtocol === 'hls') {
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
return finish('authorized (hls read key for thumbs)', 200, 'authorized_thumbnail');
}
diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
new file mode 100644
index 0000000..f2f15ce
--- /dev/null
+++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc';
+import { Button } from '@/components/ui/button';
+
+const HLS_COMPATIBLE_VIDEO_CODECS = [
+ ['h264', 'h264/90000'],
+ ['vp9', 'vp9/90000'],
+ ['av1', 'av1/90000'],
+ ['h265', 'h265/90000'],
+] as const;
+
+export default function Page() {
+ const videoRef = useRef(null);
+ const streamRef = useRef(null);
+ const publisherRef = useRef(null);
+ const [isPublishing, setIsPublishing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const startPublishing = async () => {
+ try {
+ setError(null);
+ const videoCodec = await getPreferredVideoCodec();
+
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: true,
+ audio: true,
+ });
+
+ streamRef.current = stream;
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+ }
+
+ publisherRef.current = new MediaMTXWebRTCPublisher({
+ url: 'http://localhost:8889/eth0/whip',
+ stream,
+ videoCodec,
+ videoBitrate: 2000,
+ audioCodec: 'opus',
+ audioBitrate: 64,
+ audioVoice: true,
+ user: 'user',
+ pass: '83ea0c36-57ff-4bc5-b6fe-f920b0e5d9d9',
+ onConnected: () => {
+ setIsPublishing(true);
+ },
+ onError: (message) => {
+ setError(message);
+ setIsPublishing(false);
+ },
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to start publishing');
+ }
+ };
+
+ const stopPublishing = () => {
+ publisherRef.current?.close();
+ publisherRef.current = null;
+
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+
+ setIsPublishing(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {error ?
{error}
: null}
+
+ );
+}
+
+async function getPreferredVideoCodec(): Promise {
+ const tempPc = new RTCPeerConnection();
+
+ try {
+ tempPc.addTransceiver('video', { direction: 'sendonly' });
+
+ const offer = await tempPc.createOffer();
+ const sdp = offer.sdp?.toLowerCase() ?? '';
+
+ for (const [codec, needle] of HLS_COMPATIBLE_VIDEO_CODECS) {
+ if (sdp.includes(needle)) {
+ return codec;
+ }
+ }
+ } finally {
+ tempPc.close();
+ }
+
+ throw new Error(
+ 'This browser does not expose an HLS-compatible WebRTC video codec. MediaMTX HLS supports AV1, VP9, H265, and H264, but not VP8.'
+ );
+}
diff --git a/apps/web/src/lib/utils/mediamtx/webrtc.ts b/apps/web/src/lib/utils/mediamtx/webrtc.ts
index 98505a6..68470f7 100644
--- a/apps/web/src/lib/utils/mediamtx/webrtc.ts
+++ b/apps/web/src/lib/utils/mediamtx/webrtc.ts
@@ -1,11 +1,11 @@
// based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js
// modified by codex to typescript
-type OnError = (err: string) => void;
-type OnConnected = () => void;
+export type OnError = (err: string) => void;
+export type OnConnected = () => void;
-type PublisherState = 'running' | 'restarting' | 'closed';
+export type PublisherState = 'running' | 'restarting' | 'closed';
-type PublisherConfig = {
+export type PublisherConfig = {
url: string;
user?: string;
pass?: string;
@@ -30,22 +30,28 @@ type ParsedIceServer = RTCIceServer & {
credentialType?: 'password';
};
-interface Window {
- MediaMTXWebRTCPublisher: typeof MediaMTXWebRTCPublisher;
-}
-
/** WebRTC/WHIP publisher. */
-class MediaMTXWebRTCPublisher {
+export class MediaMTXWebRTCPublisher {
private readonly retryPause = 2000;
private readonly conf: PublisherConfig;
private state: PublisherState = 'running';
- private restartTimeout: number | null = null;
+ private restartTimeout: ReturnType | null = null;
private pc: RTCPeerConnection | null = null;
private offerData: OfferData | null = null;
private sessionUrl: string | null = null;
private queuedCandidates: RTCIceCandidate[] = [];
constructor(conf: PublisherConfig) {
+ if (
+ typeof window === 'undefined'
+ || typeof RTCPeerConnection === 'undefined'
+ || typeof MediaStream === 'undefined'
+ ) {
+ throw new Error(
+ 'MediaMTXWebRTCPublisher can only be used in a browser environment.'
+ );
+ }
+
this.conf = conf;
this.start();
}
@@ -58,7 +64,7 @@ class MediaMTXWebRTCPublisher {
}
if (this.restartTimeout !== null) {
- window.clearTimeout(this.restartTimeout);
+ clearTimeout(this.restartTimeout);
}
};
@@ -303,7 +309,7 @@ class MediaMTXWebRTCPublisher {
this.queuedCandidates = [];
this.state = 'restarting';
- this.restartTimeout = window.setTimeout(() => {
+ this.restartTimeout = setTimeout(() => {
this.restartTimeout = null;
this.state = 'running';
void this.start();
@@ -495,4 +501,4 @@ class MediaMTXWebRTCPublisher {
}
}
-window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;
+export default MediaMTXWebRTCPublisher;
diff --git a/apps/web/src/lib/workers/worker/thumbnails.ts b/apps/web/src/lib/workers/worker/thumbnails.ts
index d63f6d2..c41bab9 100644
--- a/apps/web/src/lib/workers/worker/thumbnails.ts
+++ b/apps/web/src/lib/workers/worker/thumbnails.ts
@@ -45,7 +45,8 @@ export async function registerThumbnailWorker(): Promise {
);
return { success: true };
} catch (ffmpegError) {
- console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
+ // commenting since its mostly due to the fact that the stream is likely offline
+ // console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
}
} catch (e) {
From be758685d16993a7bb99b8131a07b5a3ee35fadd Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Wed, 22 Apr 2026 20:35:11 +0200
Subject: [PATCH 3/8] feat(bs): select channels and switch sources
---
.../(protected)/api/rtmp/streamKey/route.ts | 116 ++++++--
.../channel/[channelName]/page.client.tsx | 34 +--
.../src/app/(ui)/(protected)/stream/page.tsx | 167 ++++++------
.../app/ChannelSelect/ChannelSelect.tsx | 42 ++-
apps/web/src/lib/hooks/useChannelStreamKey.ts | 81 ++++++
.../src/lib/hooks/useScreensharePublisher.ts | 254 ++++++++++++++++++
apps/web/src/lib/utils/mediamtx/webrtc.ts | 115 +++++---
7 files changed, 618 insertions(+), 191 deletions(-)
create mode 100644 apps/web/src/lib/hooks/useChannelStreamKey.ts
create mode 100644 apps/web/src/lib/hooks/useScreensharePublisher.ts
diff --git a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts
index 52497d9..6c23468 100644
--- a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts
+++ b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts
@@ -1,46 +1,106 @@
+import { NextRequest } from 'next/server';
import { validateRequest } from '@/lib/auth/validate';
-import { prisma } from '@hctv/db';
-import { NextRequest } from "next/server";
import { regenerateStreamKey } from '@/lib/db/streamKey';
+import { prisma } from '@hctv/db';
export async function POST(request: NextRequest) {
+ const channelName = await readChannelNameFromBody(request);
+
+ if (!channelName) {
+ return badRequestResponse();
+ }
+
+ const result = await getAuthorizedChannel(channelName);
+ if ('response' in result) {
+ return result.response;
+ }
+
+ const streamKey = await regenerateStreamKey(result.channel.id, channelName);
+ return Response.json({ key: streamKey.key });
+}
+
+export async function GET(request: NextRequest) {
+ const channelName = request.nextUrl.searchParams.get('channel');
+
+ if (!isValidChannelName(channelName)) {
+ return badRequestResponse();
+ }
+
+ const result = await getAuthorizedChannel(channelName);
+ if ('response' in result) {
+ return result.response;
+ }
+
+ const streamKey = await prisma.streamKey.findUnique({
+ where: { channelId: result.channel.id },
+ select: { key: true },
+ });
+
+ if (!streamKey) {
+ return new Response('Stream key not found', { status: 404 });
+ }
+
+ return Response.json({ key: streamKey.key });
+}
+
+async function getAuthorizedChannel(channelName: string): Promise {
const { user } = await validateRequest();
- const body = await request.json();
- const { channel } = body;
if (!user) {
- return new Response('Unauthorized', { status: 401 });
+ return { response: unauthorizedResponse() };
}
- if (!channel || typeof channel !== 'string') {
- return new Response('Bad Request', { status: 400 });
- }
-
- const channelInfo = await prisma.channel.findUnique({
- where: { name: channel },
- include: {
- owner: true,
- managers: true
- }
+ const channel = await prisma.channel.findUnique({
+ where: { name: channelName },
+ select: {
+ id: true,
+ ownerId: true,
+ managers: {
+ where: { id: user.id },
+ select: { id: true },
+ },
+ },
});
- if (!channelInfo) {
- return new Response('Channel not found', { status: 404 });
+ if (!channel) {
+ return { response: new Response('Channel not found', { status: 404 }) };
}
- const isBroadcaster =
- channelInfo.ownerId === user.id ||
- channelInfo.managers.some(m => m.id === user.id);
+ const isBroadcaster = channel.ownerId === user.id || channel.managers.length > 0;
if (!isBroadcaster) {
- return new Response('Unauthorized', { status: 401 });
+ return { response: unauthorizedResponse() };
}
- const streamKey = await regenerateStreamKey(channelInfo.id, channel);
+ return { channel: { id: channel.id } };
+}
- return new Response(JSON.stringify({ key: streamKey.key }), {
- status: 200,
- headers: {
- 'Content-Type': 'application/json'
+async function readChannelNameFromBody(request: NextRequest) {
+ try {
+ const body = await request.json();
+ return isValidChannelName(body?.channel) ? body.channel : null;
+ } catch {
+ return null;
+ }
+}
+
+function isValidChannelName(channelName: unknown): channelName is string {
+ return typeof channelName === 'string' && channelName.length > 0;
+}
+
+function badRequestResponse() {
+ return new Response('Bad Request', { status: 400 });
+}
+
+function unauthorizedResponse() {
+ return new Response('Unauthorized', { status: 401 });
+}
+
+type AuthorizedChannelResult =
+ | {
+ channel: {
+ id: string;
+ };
}
- });
-}
\ No newline at end of file
+ | {
+ response: Response;
+ };
diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
index 484b1d5..16f9f57 100644
--- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
+++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
@@ -67,6 +67,7 @@ import { parseAsString, useQueryState } from 'nuqs';
import { Write } from '@/components/ui/channel-desc-fancy-area/write';
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
import { UploadButton } from '@/lib/uploadthing';
+import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey';
import { useOwnedChannels } from '@/lib/hooks/useUserList';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
import { useRouter } from 'next/navigation';
@@ -112,7 +113,6 @@ export default function ChannelSettingsClient({
isPersonal,
}: ChannelSettingsClientProps) {
const confirm = useConfirm();
- const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState({
streamKey: false,
@@ -123,6 +123,11 @@ export default function ChannelSettingsClient({
const [uploadError, setUploadError] = useState(null);
const [region, setRegion] = useState('hq');
const channelList = useOwnedChannels();
+ const {
+ streamKey,
+ isRegenerating: isRegeneratingStreamKey,
+ regenerateStreamKey,
+ } = useChannelStreamKey(channel.name, channel.streamKey?.key);
const router = useRouter();
const channelSettingsFormRef = useRef(null);
@@ -185,22 +190,11 @@ export default function ChannelSettingsClient({
}
};
- const regenerateStreamKey = async () => {
+ const handleRegenerateStreamKey = async () => {
try {
- const response = await fetch('/api/rtmp/streamKey', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ channel: channel.name }),
- });
-
- if (response.ok) {
- const data = await response.json();
- setStreamKey(data.key);
- toast.success('Stream key regenerated successfully');
- } else {
- toast.error('Failed to regenerate stream key');
- }
- } catch (error) {
+ await regenerateStreamKey();
+ toast.success('Stream key regenerated successfully');
+ } catch {
toast.error('Failed to regenerate stream key');
}
};
@@ -247,6 +241,7 @@ export default function ChannelSettingsClient({
c.channel)}
+ includeCreate
value={channel.name}
onSelect={(value) => {
if (value === 'create') {
@@ -561,7 +556,12 @@ export default function ChannelSettingsClient({
)}
-
-
+
+
+
+
Server
+
+
{!hasChannels && !isLoadingChannels ? (
diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts
index 5000430..f370196 100644
--- a/apps/web/src/lib/hooks/useScreensharePublisher.ts
+++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts
@@ -1,8 +1,8 @@
-// completely generated by gpt-5.4
-
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
+import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc';
const HLS_COMPATIBLE_VIDEO_CODECS = [
@@ -23,6 +23,7 @@ const DISPLAY_MEDIA_OPTIONS: ScreenCaptureOptions = {
export function useScreensharePublisher({
channelName,
+ region,
streamKey,
}: UseScreensharePublisherOptions) {
const previewRef = useRef
(null);
@@ -123,7 +124,7 @@ export function useScreensharePublisher({
commitCaptureStream(stream);
const publisher = new MediaMTXWebRTCPublisher({
- url: getWhipUrl(channelName),
+ url: getWhipUrl(channelName, region),
stream,
videoCodec,
videoBitrate: 2000,
@@ -155,7 +156,7 @@ export function useScreensharePublisher({
setPublishState('idle');
setError(getErrorMessage(err, 'Failed to start publishing'));
}
- }, [channelName, commitCaptureStream, disposeCurrentSession, streamKey]);
+ }, [channelName, commitCaptureStream, disposeCurrentSession, region, streamKey]);
const changeSource = useCallback(async () => {
const publisher = publisherRef.current;
@@ -204,8 +205,10 @@ async function requestCaptureStream() {
return navigator.mediaDevices.getDisplayMedia(DISPLAY_MEDIA_OPTIONS as DisplayMediaStreamOptions);
}
-function getWhipUrl(channelName: string) {
- return `http://localhost:8889/${encodeURIComponent(channelName)}/whip`;
+function getWhipUrl(channelName: string, region: MediaMTXRegion) {
+ const { whip } = getMediamtxClientEnvs(region);
+
+ return `${whip.replace(/\/$/, '')}/${encodeURIComponent(channelName)}/whip`;
}
function stopTracks(stream: MediaStream | null) {
@@ -243,6 +246,7 @@ type PublishState = 'idle' | 'connecting' | 'live' | 'switching';
type UseScreensharePublisherOptions = {
channelName: string;
+ region: MediaMTXRegion;
streamKey?: string | null;
};
diff --git a/apps/web/src/lib/utils/mediamtx/client.ts b/apps/web/src/lib/utils/mediamtx/client.ts
index 73edd2c..5e623f0 100644
--- a/apps/web/src/lib/utils/mediamtx/client.ts
+++ b/apps/web/src/lib/utils/mediamtx/client.ts
@@ -4,15 +4,23 @@ import { getEnv } from '@/lib/env';
export interface MediaMTXClientEnvs {
publicUrl: string;
ingestRoute: string;
+ whip: string;
emoji: string;
string: string;
}
+export interface MediaMTXClientRegionOption {
+ value: MediaMTXRegion;
+ emoji: string;
+ label: string;
+}
+
export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs {
const envs: Record = {
hq: {
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!,
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!,
+ whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ')!,
emoji: 'πΊπΈ',
string: 'HQ Server A',
},
@@ -27,3 +35,12 @@ export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXCl
return regionEnvs;
}
+export function getMediamtxClientRegionOptions(): MediaMTXClientRegionOption[] {
+ return [
+ {
+ value: 'hq',
+ emoji: 'πΊπΈ',
+ label: 'HQ Server A',
+ },
+ ];
+}
From 90d73275b2a7c305d5fe5d9dcf8665f1b6451c6c Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Wed, 22 Apr 2026 21:56:54 +0200
Subject: [PATCH 5/8] feat(bs): error handling and stuff
---
.../src/app/(ui)/(protected)/stream/page.tsx | 180 +++++++++++++++++-
.../src/lib/hooks/useScreensharePublisher.ts | 164 ++++++++++++++--
2 files changed, 329 insertions(+), 15 deletions(-)
diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
index 98d18e0..fed9623 100644
--- a/apps/web/src/app/(ui)/(protected)/stream/page.tsx
+++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
@@ -1,8 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
+import type { LucideIcon } from 'lucide-react';
+import {
+ AlertTriangle,
+ CheckCircle2,
+ CircleAlert,
+ LoaderCircle,
+ Radio,
+ RefreshCw,
+} from 'lucide-react';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
@@ -30,12 +42,14 @@ export default function Page() {
isLoading: isLoadingStreamKey,
} = useChannelStreamKey(selectedChannel || undefined);
const {
+ browserWarning,
changeSource,
- error,
+ issue,
isLive,
isSessionActive,
isStarting,
isSwitchingSource,
+ publishState,
previewRef,
startPublishing,
stopPublishing,
@@ -50,6 +64,8 @@ export default function Page() {
const canStartPublishing =
!isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
+ const statusMeta = getStatusMeta(publishState);
+ const primaryIssue = issue ?? browserWarning;
useEffect(() => {
if (isSessionActive) {
@@ -108,7 +124,70 @@ export default function Page() {
) : null}
- {streamKeyError ? {streamKeyError.message}
: null}
+
+
+
+
+
+
+
{statusMeta.title}
+
{statusMeta.badgeLabel}
+
+
{statusMeta.description}
+
+
+
+
+
+ {streamKeyError ? (
+ window.location.reload()} size="sm" variant="outline">
+
+ Reload page
+
+ }
+ description={getStreamKeyErrorDescription(streamKeyError.message)}
+ icon={CircleAlert}
+ title="Could not load the stream key"
+ tone="destructive"
+ />
+ ) : null}
+
+ {primaryIssue ? (
+
+ {!isSessionActive && primaryIssue.context !== 'warning' ? (
+
+ Try again
+
+ ) : null}
+
+ {primaryIssue.context === 'switch' && isLive ? (
+
+ Try switching again
+
+ ) : null}
+
+ {isSessionActive && primaryIssue.context !== 'warning' ? (
+
+ Stop stream
+
+ ) : null}
+ >
+ }
+ description={primaryIssue.description}
+ icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
+ title={primaryIssue.title}
+ tone={primaryIssue.tone}
+ />
+ ) : null}
-
- {error ? {error}
: null}
);
}
+
+function ActionPanel({ actions, description, icon: Icon, title, tone }: ActionPanelProps) {
+ const isWarning = tone === 'warning';
+
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ {actions ?
{actions}
: null}
+
+
+ );
+}
+
+function getStatusMeta(publishState: PublishState) {
+ switch (publishState) {
+ case 'connecting':
+ return {
+ badgeLabel: 'Starting',
+ badgeVariant: 'secondary' as const,
+ cardClassName: 'border-blue-500/30 bg-blue-500/5',
+ description: 'Approve the browser picker and keep this page open while we connect.',
+ icon: LoaderCircle,
+ iconClassName: 'animate-spin text-blue-500',
+ title: 'Preparing your stream',
+ };
+ case 'live':
+ return {
+ badgeLabel: 'Live',
+ badgeVariant: 'default' as const,
+ cardClassName: 'border-emerald-500/30 bg-emerald-500/5',
+ description: 'Your stream is live. You can switch sources without ending the broadcast.',
+ icon: Radio,
+ iconClassName: 'text-emerald-500',
+ title: 'Broadcast is live',
+ };
+ case 'switching':
+ return {
+ badgeLabel: 'Switching',
+ badgeVariant: 'secondary' as const,
+ cardClassName: 'border-amber-500/30 bg-amber-500/5',
+ description: 'Choose a new window, tab, or display in the browser picker.',
+ icon: LoaderCircle,
+ iconClassName: 'animate-spin text-amber-500',
+ title: 'Switching shared source',
+ };
+ default:
+ return {
+ badgeLabel: 'Ready',
+ badgeVariant: 'outline' as const,
+ cardClassName: '',
+ description: 'Choose a channel and server, then start sharing your screen.',
+ icon: CheckCircle2,
+ iconClassName: 'text-primary',
+ title: 'Ready to stream',
+ };
+ }
+}
+
+function getStreamKeyErrorDescription(message: string) {
+ if (message.toLowerCase().includes('unauthorized')) {
+ return 'You no longer have permission to stream to this channel. Try another channel or sign in again.';
+ }
+
+ if (message.toLowerCase().includes('not found')) {
+ return 'This channel does not have a valid stream key yet. Regenerate it in channel settings, then retry.';
+ }
+
+ return 'Refresh the page and try again. If it keeps failing, check channel settings and server config.';
+}
+
+type ActionPanelProps = {
+ actions?: ReactNode;
+ description: string;
+ icon: LucideIcon;
+ title: string;
+ tone: 'warning' | 'destructive';
+};
+
+type PublishState = 'idle' | 'connecting' | 'live' | 'switching';
diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts
index f370196..ab33dbe 100644
--- a/apps/web/src/lib/hooks/useScreensharePublisher.ts
+++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts
@@ -1,6 +1,6 @@
'use client';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc';
@@ -31,7 +31,8 @@ export function useScreensharePublisher({
const captureCleanupRef = useRef<(() => void) | null>(null);
const publisherRef = useRef(null);
const [publishState, setPublishState] = useState('idle');
- const [error, setError] = useState(null);
+ const [issue, setIssue] = useState(null);
+ const browserWarning = useMemo(() => getBrowserWarning(), []);
const setPreviewStream = useCallback((stream: MediaStream | null) => {
if (previewRef.current) {
@@ -65,7 +66,7 @@ export function useScreensharePublisher({
const stopPublishing = useCallback(() => {
disposeCurrentSession();
- setError(null);
+ setIssue(null);
setPublishState('idle');
}, [disposeCurrentSession]);
@@ -105,17 +106,27 @@ export function useScreensharePublisher({
const startPublishing = useCallback(async () => {
if (!channelName) {
- setError('Select a channel before starting your stream.');
+ setIssue({
+ context: 'start',
+ description: 'Pick a channel first so we know where to publish.',
+ title: 'Choose a channel before starting',
+ tone: 'warning',
+ });
return;
}
if (!streamKey) {
- setError('Stream key unavailable for the selected channel.');
+ setIssue({
+ context: 'start',
+ description: 'Wait for the stream key to load, then try starting again.',
+ title: 'Stream key is still unavailable',
+ tone: 'warning',
+ });
return;
}
try {
- setError(null);
+ setIssue(null);
setPublishState('connecting');
const videoCodec = await getPreferredVideoCodec();
@@ -145,7 +156,7 @@ export function useScreensharePublisher({
return;
}
- setError(message);
+ setIssue(classifyPublisherIssue(message, 'publish'));
setPublishState('connecting');
},
});
@@ -154,7 +165,7 @@ export function useScreensharePublisher({
} catch (err) {
disposeCurrentSession();
setPublishState('idle');
- setError(getErrorMessage(err, 'Failed to start publishing'));
+ setIssue(classifyPublisherIssue(err, 'start'));
}
}, [channelName, commitCaptureStream, disposeCurrentSession, region, streamKey]);
@@ -168,7 +179,7 @@ export function useScreensharePublisher({
let nextStream: MediaStream | null = null;
try {
- setError(null);
+ setIssue(null);
setPublishState('switching');
nextStream = await requestCaptureStream();
@@ -178,7 +189,7 @@ export function useScreensharePublisher({
} catch (err) {
stopTracks(nextStream);
setPublishState(publisherRef.current ? 'live' : 'idle');
- setError(getErrorMessage(err, 'Failed to change screenshare source'));
+ setIssue(classifyPublisherIssue(err, 'switch'));
}
}, [commitCaptureStream]);
@@ -189,12 +200,14 @@ export function useScreensharePublisher({
}, [disposeCurrentSession]);
return {
+ browserWarning,
changeSource,
- error,
+ issue,
isLive: publishState === 'live',
isSessionActive: publishState !== 'idle',
isStarting: publishState === 'connecting',
isSwitchingSource: publishState === 'switching',
+ publishState,
previewRef,
startPublishing,
stopPublishing,
@@ -219,6 +232,126 @@ function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
+function classifyPublisherIssue(error: unknown, context: PublisherIssueContext): PublisherIssue {
+ const message = getErrorMessage(
+ error,
+ context === 'switch' ? 'Failed to change screenshare source' : 'Failed to start publishing'
+ );
+ const normalizedMessage = message.toLowerCase();
+
+ if (normalizedMessage.includes('notallowederror') || normalizedMessage.includes('permission')) {
+ return {
+ context,
+ description:
+ context === 'switch'
+ ? 'Choose a new tab, window, or display in the browser picker to continue the broadcast.'
+ : 'Approve the browser screen-share prompt, then try again.',
+ title:
+ context === 'switch'
+ ? 'Source switch was cancelled or blocked'
+ : 'Screen-share permission was denied',
+ tone: 'warning',
+ };
+ }
+
+ if (normalizedMessage.includes('notfounderror')) {
+ return {
+ context,
+ description:
+ 'Open the window or tab you want to capture, then retry the screen-share picker.',
+ title: 'No capturable source was found',
+ tone: 'warning',
+ };
+ }
+
+ if (
+ normalizedMessage.includes('getdisplaymedia') ||
+ normalizedMessage.includes('secure context') ||
+ normalizedMessage.includes('browser environment')
+ ) {
+ return {
+ context,
+ description:
+ 'Use HackClub.tv over HTTPS or localhost in a Chromium-based browser, then try again.',
+ title: 'This browser or page cannot start screen sharing',
+ tone: 'destructive',
+ };
+ }
+
+ if (normalizedMessage.includes('hls-compatible webrtc video codec')) {
+ return {
+ context,
+ description:
+ 'Switch to a Chromium-based browser. Firefox and Safari can expose codecs that our ingest pipeline cannot use reliably yet.',
+ title: 'This browser cannot publish a compatible stream codec',
+ tone: 'destructive',
+ };
+ }
+
+ if (normalizedMessage.includes('invalid stream key') || normalizedMessage.includes('403')) {
+ return {
+ context,
+ description:
+ 'Refresh the page or regenerate the stream key in channel settings if this keeps happening.',
+ title: 'The ingest server rejected your stream key',
+ tone: 'destructive',
+ };
+ }
+
+ if (normalizedMessage.includes('404')) {
+ return {
+ context,
+ description:
+ 'The selected ingest server may be misconfigured or offline. Try another server or retry in a moment.',
+ title: 'The selected ingest server could not be reached',
+ tone: 'destructive',
+ };
+ }
+
+ if (normalizedMessage.includes('retrying in some seconds')) {
+ return {
+ context,
+ description:
+ 'We are retrying automatically. Keep this page open, or stop and start again if it does not recover.',
+ title: 'Connection to the ingest server dropped',
+ tone: 'warning',
+ };
+ }
+
+ return {
+ context,
+ description:
+ context === 'switch'
+ ? 'Try choosing the source again. If it keeps failing, stop the stream and start a new session.'
+ : 'Try again. If it keeps failing, switch servers or reload the page.',
+ title:
+ context === 'switch' ? 'Could not switch the shared source' : 'Could not start the stream',
+ tone: 'destructive',
+ };
+}
+
+function getBrowserWarning(): PublisherIssue | null {
+ if (typeof navigator === 'undefined') {
+ return null;
+ }
+
+ const userAgent = navigator.userAgent.toLowerCase();
+ const isChromium =
+ userAgent.includes('chrome') || userAgent.includes('chromium') || userAgent.includes('edg/');
+
+ if (isChromium) {
+ return null;
+ }
+
+ return {
+ context: 'warning',
+ description:
+ 'You can still try this here, but screen capture and source switching are most reliable in Chrome or another Chromium-based browser.',
+ title: 'This browser is supported on a best-effort basis',
+ tone: 'warning',
+ };
+}
+
async function getPreferredVideoCodec(): Promise {
const tempPc = new RTCPeerConnection();
@@ -250,6 +383,15 @@ type UseScreensharePublisherOptions = {
streamKey?: string | null;
};
+type PublisherIssue = {
+ context: PublisherIssueContext;
+ description: string;
+ title: string;
+ tone: 'warning' | 'destructive';
+};
+
+type PublisherIssueContext = 'publish' | 'start' | 'switch' | 'warning';
+
type ScreenCaptureOptions = DisplayMediaStreamOptions & {
monitorTypeSurfaces?: 'include' | 'exclude';
selfBrowserSurface?: 'include' | 'exclude';
From 9e60e1dfe222475dc835da538e3c691aa8d85661 Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Thu, 23 Apr 2026 20:20:06 +0200
Subject: [PATCH 6/8] feat(bs): redesign ui and add preview
---
.../src/app/(ui)/(protected)/stream/page.tsx | 447 +++++++++++-------
.../src/lib/hooks/useScreensharePublisher.ts | 66 ++-
2 files changed, 316 insertions(+), 197 deletions(-)
diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
index fed9623..c6b5094 100644
--- a/apps/web/src/app/(ui)/(protected)/stream/page.tsx
+++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
@@ -5,13 +5,17 @@ import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
AlertTriangle,
- CheckCircle2,
CircleAlert,
+ Globe,
LoaderCircle,
+ Monitor,
Radio,
RefreshCw,
+ Square,
+ Video,
} from 'lucide-react';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
+import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
@@ -44,13 +48,16 @@ export default function Page() {
const {
browserWarning,
changeSource,
+ hasPreview,
issue,
isLive,
+ isPreviewReady,
+ isPreviewingSource,
isSessionActive,
isStarting,
isSwitchingSource,
- publishState,
previewRef,
+ previewSource,
startPublishing,
stopPublishing,
} = useScreensharePublisher({
@@ -62,9 +69,12 @@ export default function Page() {
const hasChannels = ownedChannels.length > 0;
const hasServerOptions = serverOptions.length > 0;
const canStartPublishing =
- !isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey;
+ !isSessionActive &&
+ !isPreviewingSource &&
+ Boolean(selectedChannel) &&
+ Boolean(streamKey) &&
+ !isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
- const statusMeta = getStatusMeta(publishState);
const primaryIssue = issue ?? browserWarning;
useEffect(() => {
@@ -77,219 +87,290 @@ export default function Page() {
}
}, [isSessionActive, ownedChannels, selectedChannel]);
+ const statusLabel = isLive
+ ? 'LIVE'
+ : isSwitchingSource
+ ? 'Switching'
+ : isStarting
+ ? 'Connecting'
+ : isPreviewingSource
+ ? hasPreview
+ ? 'Updating Preview'
+ : 'Preparing Preview'
+ : isPreviewReady
+ ? 'Preview'
+ : 'Ready';
+
return (
-
-
- Start a screenshare stream, then switch windows, tabs, or displays without ending the
- broadcast.
-
+
+ {/* Video Stage */}
+
+
+
+
+
-
-
+ {!hasPreview && (
+
+
+
+
+
+
+ Ready to livestream
+
+
+ Select a tab, window, or display to preview.
+
+
+
+ )}
-
-
Server
-
+ {(isPreviewingSource || isStarting || isSwitchingSource) && (
+
+
+
+ {isPreviewingSource
+ ? hasPreview
+ ? 'Updating preview...'
+ : 'Preparing preview...'
+ : isStarting
+ ? 'Starting broadcast...'
+ : 'Switching source...'}
+
+
+ )}
+
+
+
+ {isLive && }
+ {statusLabel}
+
+
+
+
- {!hasChannels && !isLoadingChannels ? (
-
- You need at least one channel before you can publish.
-
- ) : null}
+ {(streamKeyError || primaryIssue) && (
+
+ {streamKeyError ? (
+
window.location.reload()} size="sm" variant="outline">
+
+ Reload page
+
+ }
+ description={getStreamKeyErrorDescription(streamKeyError.message)}
+ icon={CircleAlert}
+ title="Could not load the stream key"
+ tone="destructive"
+ />
+ ) : null}
-
-
-
-
-
-
-
{statusMeta.title}
-
{statusMeta.badgeLabel}
-
-
{statusMeta.description}
+ {primaryIssue ? (
+
+ {!isSessionActive && primaryIssue.context === 'preview' ? (
+
+ Preview again
+
+ ) : null}
+
+ {!isSessionActive &&
+ primaryIssue.context !== 'warning' &&
+ primaryIssue.context !== 'preview' ? (
+
+ Try again
+
+ ) : null}
+
+ {primaryIssue.context === 'switch' && isLive ? (
+
+ Try switching again
+
+ ) : null}
+
+ {isSessionActive && primaryIssue.context !== 'warning' ? (
+
+ Stop stream
+
+ ) : null}
+
+ }
+ description={primaryIssue.description}
+ icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
+ title={primaryIssue.title}
+ tone={primaryIssue.tone}
+ />
+ ) : null}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!hasChannels && !isLoadingChannels ? (
+
Create a channel to stream.
+ ) : null}
-
-
- {streamKeyError ? (
-
window.location.reload()} size="sm" variant="outline">
-
- Reload page
-
- }
- description={getStreamKeyErrorDescription(streamKeyError.message)}
- icon={CircleAlert}
- title="Could not load the stream key"
- tone="destructive"
- />
- ) : null}
-
- {primaryIssue ? (
-
- {!isSessionActive && primaryIssue.context !== 'warning' ? (
-
- Try again
-
- ) : null}
-
- {primaryIssue.context === 'switch' && isLive ? (
+ {/* Right: Actions */}
+
+ {!isSessionActive ? (
+
- Try switching again
+
+ {hasPreview ? 'Change Preview' : 'Preview'}
- ) : null}
- {isSessionActive && primaryIssue.context !== 'warning' ? (
-
- Stop stream
+ {hasPreview ? (
+
+
+ Clear Preview
+
+ ) : null}
+
+
+
+ Start
+
+
+ ) : (
+
+
+
+ Switch
- ) : null}
- >
- }
- description={primaryIssue.description}
- icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
- title={primaryIssue.title}
- tone={primaryIssue.tone}
- />
- ) : null}
-
-
-
-
- Start
-
-
- Change source
-
-
- Stop
-
+
+
+ Stop
+
+
+ )}
+
+
);
}
-function ActionPanel({ actions, description, icon: Icon, title, tone }: ActionPanelProps) {
+function AlertCard({ actions, description, icon: Icon, title, tone }: AlertCardProps) {
const isWarning = tone === 'warning';
return (
-
-
+
-
{title}
+
{title}
{description}
-
- {actions ? {actions}
: null}
-
-
+ {actions ? {actions}
: null}
+
+
);
}
-function getStatusMeta(publishState: PublishState) {
- switch (publishState) {
- case 'connecting':
- return {
- badgeLabel: 'Starting',
- badgeVariant: 'secondary' as const,
- cardClassName: 'border-blue-500/30 bg-blue-500/5',
- description: 'Approve the browser picker and keep this page open while we connect.',
- icon: LoaderCircle,
- iconClassName: 'animate-spin text-blue-500',
- title: 'Preparing your stream',
- };
- case 'live':
- return {
- badgeLabel: 'Live',
- badgeVariant: 'default' as const,
- cardClassName: 'border-emerald-500/30 bg-emerald-500/5',
- description: 'Your stream is live. You can switch sources without ending the broadcast.',
- icon: Radio,
- iconClassName: 'text-emerald-500',
- title: 'Broadcast is live',
- };
- case 'switching':
- return {
- badgeLabel: 'Switching',
- badgeVariant: 'secondary' as const,
- cardClassName: 'border-amber-500/30 bg-amber-500/5',
- description: 'Choose a new window, tab, or display in the browser picker.',
- icon: LoaderCircle,
- iconClassName: 'animate-spin text-amber-500',
- title: 'Switching shared source',
- };
- default:
- return {
- badgeLabel: 'Ready',
- badgeVariant: 'outline' as const,
- cardClassName: '',
- description: 'Choose a channel and server, then start sharing your screen.',
- icon: CheckCircle2,
- iconClassName: 'text-primary',
- title: 'Ready to stream',
- };
- }
-}
-
function getStreamKeyErrorDescription(message: string) {
if (message.toLowerCase().includes('unauthorized')) {
return 'You no longer have permission to stream to this channel. Try another channel or sign in again.';
@@ -302,12 +383,10 @@ function getStreamKeyErrorDescription(message: string) {
return 'Refresh the page and try again. If it keeps failing, check channel settings and server config.';
}
-type ActionPanelProps = {
+type AlertCardProps = {
actions?: ReactNode;
description: string;
icon: LucideIcon;
title: string;
tone: 'warning' | 'destructive';
};
-
-type PublishState = 'idle' | 'connecting' | 'live' | 'switching';
diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts
index ab33dbe..d17f0f7 100644
--- a/apps/web/src/lib/hooks/useScreensharePublisher.ts
+++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts
@@ -31,6 +31,7 @@ export function useScreensharePublisher({
const captureCleanupRef = useRef<(() => void) | null>(null);
const publisherRef = useRef(null);
const [publishState, setPublishState] = useState('idle');
+ const [hasPreview, setHasPreview] = useState(false);
const [issue, setIssue] = useState(null);
const browserWarning = useMemo(() => getBrowserWarning(), []);
@@ -49,6 +50,7 @@ export function useScreensharePublisher({
detachCaptureCleanup();
stopTracks(captureStreamRef.current);
captureStreamRef.current = null;
+ setHasPreview(false);
setPreviewStream(null);
}, [detachCaptureCleanup, setPreviewStream]);
@@ -97,6 +99,7 @@ export function useScreensharePublisher({
detachCaptureCleanup();
captureStreamRef.current = nextStream;
+ setHasPreview(true);
setPreviewStream(nextStream);
attachCaptureStopListener(nextStream);
stopTracks(previousStream);
@@ -104,6 +107,21 @@ export function useScreensharePublisher({
[attachCaptureStopListener, detachCaptureCleanup, setPreviewStream]
);
+ const previewSource = useCallback(async () => {
+ try {
+ setIssue(null);
+ setPublishState('previewing');
+
+ const stream = await requestCaptureStream();
+
+ commitCaptureStream(stream);
+ setPublishState('preview');
+ } catch (err) {
+ setPublishState(captureStreamRef.current ? 'preview' : 'idle');
+ setIssue(classifyPublisherIssue(err, 'preview'));
+ }
+ }, [commitCaptureStream]);
+
const startPublishing = useCallback(async () => {
if (!channelName) {
setIssue({
@@ -130,9 +148,12 @@ export function useScreensharePublisher({
setPublishState('connecting');
const videoCodec = await getPreferredVideoCodec();
- const stream = await requestCaptureStream();
+ let stream = captureStreamRef.current;
- commitCaptureStream(stream);
+ if (!stream) {
+ stream = await requestCaptureStream();
+ commitCaptureStream(stream);
+ }
const publisher = new MediaMTXWebRTCPublisher({
url: getWhipUrl(channelName, region),
@@ -163,11 +184,11 @@ export function useScreensharePublisher({
publisherRef.current = publisher;
} catch (err) {
- disposeCurrentSession();
- setPublishState('idle');
+ closePublisher();
+ setPublishState(captureStreamRef.current ? 'preview' : 'idle');
setIssue(classifyPublisherIssue(err, 'start'));
}
- }, [channelName, commitCaptureStream, disposeCurrentSession, region, streamKey]);
+ }, [channelName, closePublisher, commitCaptureStream, region, streamKey]);
const changeSource = useCallback(async () => {
const publisher = publisherRef.current;
@@ -202,13 +223,18 @@ export function useScreensharePublisher({
return {
browserWarning,
changeSource,
+ hasPreview,
issue,
isLive: publishState === 'live',
- isSessionActive: publishState !== 'idle',
+ isPreviewReady: publishState === 'preview',
+ isPreviewingSource: publishState === 'previewing',
+ isSessionActive:
+ publishState === 'connecting' || publishState === 'live' || publishState === 'switching',
isStarting: publishState === 'connecting',
isSwitchingSource: publishState === 'switching',
publishState,
previewRef,
+ previewSource,
startPublishing,
stopPublishing,
};
@@ -235,7 +261,11 @@ function getErrorMessage(error: unknown, fallback: string) {
function classifyPublisherIssue(error: unknown, context: PublisherIssueContext): PublisherIssue {
const message = getErrorMessage(
error,
- context === 'switch' ? 'Failed to change screenshare source' : 'Failed to start publishing'
+ context === 'switch'
+ ? 'Failed to change screenshare source'
+ : context === 'preview'
+ ? 'Failed to preview the selected source'
+ : 'Failed to start publishing'
);
const normalizedMessage = message.toLowerCase();
@@ -245,11 +275,15 @@ function classifyPublisherIssue(error: unknown, context: PublisherIssueContext):
description:
context === 'switch'
? 'Choose a new tab, window, or display in the browser picker to continue the broadcast.'
- : 'Approve the browser screen-share prompt, then try again.',
+ : context === 'preview'
+ ? 'Approve the browser screen-share prompt so we can load your preview.'
+ : 'Approve the browser screen-share prompt, then try again.',
title:
context === 'switch'
? 'Source switch was cancelled or blocked'
- : 'Screen-share permission was denied',
+ : context === 'preview'
+ ? 'Preview permission was denied'
+ : 'Screen-share permission was denied',
tone: 'warning',
};
}
@@ -323,9 +357,15 @@ function classifyPublisherIssue(error: unknown, context: PublisherIssueContext):
description:
context === 'switch'
? 'Try choosing the source again. If it keeps failing, stop the stream and start a new session.'
- : 'Try again. If it keeps failing, switch servers or reload the page.',
+ : context === 'preview'
+ ? 'Try choosing the source again. If it keeps failing, reload the page or switch browsers.'
+ : 'Try again. If it keeps failing, switch servers or reload the page.',
title:
- context === 'switch' ? 'Could not switch the shared source' : 'Could not start the stream',
+ context === 'switch'
+ ? 'Could not switch the shared source'
+ : context === 'preview'
+ ? 'Could not load the preview'
+ : 'Could not start the stream',
tone: 'destructive',
};
}
@@ -375,7 +415,7 @@ async function getPreferredVideoCodec(): Promise {
);
}
-type PublishState = 'idle' | 'connecting' | 'live' | 'switching';
+type PublishState = 'idle' | 'previewing' | 'preview' | 'connecting' | 'live' | 'switching';
type UseScreensharePublisherOptions = {
channelName: string;
@@ -390,7 +430,7 @@ type PublisherIssue = {
tone: 'warning' | 'destructive';
};
-type PublisherIssueContext = 'publish' | 'start' | 'switch' | 'warning';
+type PublisherIssueContext = 'preview' | 'publish' | 'start' | 'switch' | 'warning';
type ScreenCaptureOptions = DisplayMediaStreamOptions & {
monitorTypeSurfaces?: 'include' | 'exclude';
From 728dcd971255a0e68e990046e6b30a9f48f568d3 Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Thu, 23 Apr 2026 21:58:07 +0200
Subject: [PATCH 7/8] chore(bs): config file edits
---
apps/web/.env.example | 1 +
docker/mediamtx/mediamtx.yml | 2 ++
2 files changed, 3 insertions(+)
diff --git a/apps/web/.env.example b/apps/web/.env.example
index ad48fcd..84b2f95 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -21,6 +21,7 @@ HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback
NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891
MEDIAMTX_API_HQ=http://localhost:9997
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
+NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ=http://localhost:8889
# commented because we don't have another ingest server as of right now
# NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991
diff --git a/docker/mediamtx/mediamtx.yml b/docker/mediamtx/mediamtx.yml
index 3a1395e..ebcabd8 100644
--- a/docker/mediamtx/mediamtx.yml
+++ b/docker/mediamtx/mediamtx.yml
@@ -11,6 +11,8 @@ hlsSegmentDuration: 2s
hlsPartDuration: 1s
hlsSegmentCount: 10
+webrtc: yes
+
authMethod: http
authHTTPAddress: http://hctv:3000/api/mediamtx/publish
From 953bc38c12d9c653feabd05c71ceb3018f8caa5d Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:14:48 +0200
Subject: [PATCH 8/8] feat(bs): production prepping
---
apps/web/.env.example | 5 +-
.../(protected)/api/mediamtx/publish/route.ts | 7 +++
.../src/app/(ui)/(protected)/stream/page.tsx | 6 +-
apps/web/src/components/app/NavBar/NavBar.tsx | 23 ++++++-
.../web/src/lib/instrumentation/streamInfo.ts | 10 ++-
apps/web/src/lib/utils/mediamtx/client.ts | 12 ++++
apps/web/src/lib/utils/mediamtx/regions.ts | 2 +-
apps/web/src/lib/utils/mediamtx/server.ts | 12 ++++
docker/mediamtx/mediamtx.yml | 6 +-
docker/mediamtx/mirror/.env.example | 12 ++++
docker/mediamtx/mirror/docker-compose.yml | 63 +++++++++++++++++++
docker/mediamtx/mirror/mediamtx.yml | 26 ++++++++
12 files changed, 177 insertions(+), 7 deletions(-)
create mode 100644 docker/mediamtx/mirror/.env.example
create mode 100644 docker/mediamtx/mirror/docker-compose.yml
create mode 100644 docker/mediamtx/mirror/mediamtx.yml
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 84b2f95..ae709a0 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -28,5 +28,6 @@ NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ=http://localhost:8889
# MEDIAMTX_API_ASIA=http://localhost:9999
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990
-# idt you should change this
-MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge
\ No newline at end of file
+# generate with `openssl rand -base64 20`
+MEDIAMTX_PUBLISH_KEY=
+MEDIAMTX_API_KEY=
diff --git a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
index e9b4083..919aff2 100644
--- a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
+++ b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts
@@ -80,6 +80,13 @@ export async function POST(request: NextRequest) {
}
return finish('authorized', 200, 'authorized_read');
}
+ if (parsedAction === 'api') {
+ if (password === process.env.MEDIAMTX_API_KEY) {
+ return finish('authorized api', 200, 'authorized_api');
+ }
+
+ return finish('unauthorized api', 401, 'unauthorized_api');
+ }
return finish('uhh', 401, 'unauthorized');
}
diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
index c6b5094..524aefc 100644
--- a/apps/web/src/app/(ui)/(protected)/stream/page.tsx
+++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
@@ -259,7 +259,11 @@ export default function Page() {
{serverOptions.map((server) => (
-
+
{server.label} {server.emoji}
))}
diff --git a/apps/web/src/components/app/NavBar/NavBar.tsx b/apps/web/src/components/app/NavBar/NavBar.tsx
index 102a2d5..1914bfb 100644
--- a/apps/web/src/components/app/NavBar/NavBar.tsx
+++ b/apps/web/src/components/app/NavBar/NavBar.tsx
@@ -15,7 +15,18 @@ import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
-import { IdCard, Shield, Settings, Users, PenSquare, LogOut, Code, Github, Heart } from 'lucide-react';
+import {
+ IdCard,
+ Shield,
+ Settings,
+ Users,
+ PenSquare,
+ LogOut,
+ Code,
+ Github,
+ Heart,
+ Radio,
+} from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
import Image from 'next/image';
import Logo from '@/lib/assets/logo.webp';
@@ -52,6 +63,16 @@ export default function Navbar(props: Props) {
{/* Right Side Items */}
+ {user && (
+
+
+
+ Go live
+ Live
+
+
+ )}
+
{props.editLivestream &&
{props.editLivestream}
}
{user ? (
diff --git a/apps/web/src/lib/instrumentation/streamInfo.ts b/apps/web/src/lib/instrumentation/streamInfo.ts
index 32684dd..39bdecf 100644
--- a/apps/web/src/lib/instrumentation/streamInfo.ts
+++ b/apps/web/src/lib/instrumentation/streamInfo.ts
@@ -90,7 +90,15 @@ export async function syncStream() {
for (const r of regions) {
const region = MEDIAMTX_SERVER_REGIONS[r];
- const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`);
+ if (!region.apiAuthHeader) {
+ throw new Error('MEDIAMTX_API_KEY is required when querying the MediaMTX API');
+ }
+
+ const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`, {
+ headers: {
+ Authorization: region.apiAuthHeader,
+ },
+ });
if (!response.ok) {
recordStreamSyncScrape(r, 'error');
diff --git a/apps/web/src/lib/utils/mediamtx/client.ts b/apps/web/src/lib/utils/mediamtx/client.ts
index 5e623f0..edef96d 100644
--- a/apps/web/src/lib/utils/mediamtx/client.ts
+++ b/apps/web/src/lib/utils/mediamtx/client.ts
@@ -5,6 +5,7 @@ export interface MediaMTXClientEnvs {
publicUrl: string;
ingestRoute: string;
whip: string;
+ whipEnabled: boolean;
emoji: string;
string: string;
}
@@ -13,6 +14,7 @@ export interface MediaMTXClientRegionOption {
value: MediaMTXRegion;
emoji: string;
label: string;
+ whipEnabled: boolean;
}
export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs {
@@ -21,9 +23,18 @@ export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXCl
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!,
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!,
whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ')!,
+ whipEnabled: false,
emoji: 'πΊπΈ',
string: 'HQ Server A',
},
+ ethande: {
+ publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_ETHANDE')!,
+ ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ETHANDE')!,
+ whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_ETHANDE')!,
+ whipEnabled: true,
+ emoji: 'π©πͺ',
+ string: 'eth0\'s VPS',
+ },
};
const regionEnvs = envs[region];
@@ -41,6 +52,7 @@ export function getMediamtxClientRegionOptions(): MediaMTXClientRegionOption[] {
value: 'hq',
emoji: 'πΊπΈ',
label: 'HQ Server A',
+ whipEnabled: false,
},
];
}
diff --git a/apps/web/src/lib/utils/mediamtx/regions.ts b/apps/web/src/lib/utils/mediamtx/regions.ts
index 4140a62..0a3a23a 100644
--- a/apps/web/src/lib/utils/mediamtx/regions.ts
+++ b/apps/web/src/lib/utils/mediamtx/regions.ts
@@ -1 +1 @@
-export type MediaMTXRegion = 'hq';
+export type MediaMTXRegion = 'hq' | 'ethande';
diff --git a/apps/web/src/lib/utils/mediamtx/server.ts b/apps/web/src/lib/utils/mediamtx/server.ts
index 8866c46..73033b2 100644
--- a/apps/web/src/lib/utils/mediamtx/server.ts
+++ b/apps/web/src/lib/utils/mediamtx/server.ts
@@ -2,11 +2,13 @@ import { MediaMTXRegion } from './regions';
export interface MediaMTXEnvs {
apiUrl: string;
+ apiAuthHeader?: string;
}
export const MEDIAMTX_SERVER_REGIONS: Record
= {
hq: {
apiUrl: process.env.MEDIAMTX_API_HQ!,
+ apiAuthHeader: getMediamtxApiAuthHeader(),
},
};
@@ -19,3 +21,13 @@ export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs {
return envs;
}
+
+function getMediamtxApiAuthHeader() {
+ const apiKey = process.env.MEDIAMTX_API_KEY;
+
+ if (!apiKey) {
+ return undefined;
+ }
+
+ return `Basic ${Buffer.from(`hctv-api:${apiKey}`).toString('base64')}`;
+}
diff --git a/docker/mediamtx/mediamtx.yml b/docker/mediamtx/mediamtx.yml
index ebcabd8..284bae8 100644
--- a/docker/mediamtx/mediamtx.yml
+++ b/docker/mediamtx/mediamtx.yml
@@ -12,10 +12,14 @@ hlsPartDuration: 1s
hlsSegmentCount: 10
webrtc: yes
+webrtcAddress: :8889
+webrtcLocalUDPAddress: :8189
+webrtcAdditionalHosts: []
authMethod: http
-authHTTPAddress: http://hctv:3000/api/mediamtx/publish
+authHTTPAddress: https://hackclub.tv/api/mediamtx/publish
api: yes
+apiAddress: 0.0.0.0:9997
metrics: yes
metricsAddress: :9998
diff --git a/docker/mediamtx/mirror/.env.example b/docker/mediamtx/mirror/.env.example
new file mode 100644
index 0000000..f0ed859
--- /dev/null
+++ b/docker/mediamtx/mirror/.env.example
@@ -0,0 +1,12 @@
+ACME_EMAIL=ops@hackclub.tv
+
+# public hostnames and stuff
+MEDIAMTX_HLS_HOST=hls.hackclub.tv
+MEDIAMTX_WEBRTC_HOST=whip.hackclub.tv
+MEDIAMTX_API_HOST=mmtxapi.hackclub.tv
+
+# public ip for webrtc stuff
+MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS=203.0.113.10
+
+# mediamtx publish route on hctv
+MEDIAMTX_AUTH_HTTP_ADDRESS=https://hackclub.tv/api/mediamtx/publish
diff --git a/docker/mediamtx/mirror/docker-compose.yml b/docker/mediamtx/mirror/docker-compose.yml
new file mode 100644
index 0000000..d2371df
--- /dev/null
+++ b/docker/mediamtx/mirror/docker-compose.yml
@@ -0,0 +1,63 @@
+services:
+ traefik:
+ image: traefik:v3.5
+ command:
+ - --providers.docker=true
+ - --providers.docker.exposedbydefault=false
+ - --entrypoints.web.address=:80
+ - --entrypoints.websecure.address=:443
+ - --entrypoints.srt.address=:8890/udp
+ - --entrypoints.webrtc-ice.address=:8189/udp
+ - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
+ - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
+ - --certificatesresolvers.letsencrypt.acme.httpchallenge=true
+ - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
+ ports:
+ - 80:80
+ - 443:443
+ - 8890:8890/udp
+ - 8189:8189/udp
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - letsencrypt:/letsencrypt
+ restart: unless-stopped
+
+ mediamtx:
+ image: bluenviron/mediamtx:1
+ volumes:
+ - ./mediamtx.yml:/mediamtx.yml:ro
+ environment:
+ MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS}
+ MTX_AUTHHTTPADDRESS: ${MEDIAMTX_AUTH_HTTP_ADDRESS}
+ labels:
+ - traefik.enable=true
+
+ - traefik.http.routers.mediamtx-hls.rule=Host(`${MEDIAMTX_HLS_HOST}`)
+ - traefik.http.routers.mediamtx-hls.entrypoints=websecure
+ - traefik.http.routers.mediamtx-hls.tls.certresolver=letsencrypt
+ - traefik.http.routers.mediamtx-hls.service=mediamtx-hls
+ - traefik.http.services.mediamtx-hls.loadbalancer.server.port=8888
+
+ - traefik.http.routers.mediamtx-webrtc.rule=Host(`${MEDIAMTX_WEBRTC_HOST}`)
+ - traefik.http.routers.mediamtx-webrtc.entrypoints=websecure
+ - traefik.http.routers.mediamtx-webrtc.tls.certresolver=letsencrypt
+ - traefik.http.routers.mediamtx-webrtc.service=mediamtx-webrtc
+ - traefik.http.services.mediamtx-webrtc.loadbalancer.server.port=8889
+
+ - traefik.http.routers.mediamtx-api.rule=Host(`${MEDIAMTX_API_HOST}`)
+ - traefik.http.routers.mediamtx-api.entrypoints=websecure
+ - traefik.http.routers.mediamtx-api.tls.certresolver=letsencrypt
+ - traefik.http.routers.mediamtx-api.service=mediamtx-api
+ - traefik.http.services.mediamtx-api.loadbalancer.server.port=9997
+
+ - traefik.udp.routers.mediamtx-srt.entrypoints=srt
+ - traefik.udp.routers.mediamtx-srt.service=mediamtx-srt
+ - traefik.udp.services.mediamtx-srt.loadbalancer.server.port=8890
+
+ - traefik.udp.routers.mediamtx-webrtc-ice.entrypoints=webrtc-ice
+ - traefik.udp.routers.mediamtx-webrtc-ice.service=mediamtx-webrtc-ice
+ - traefik.udp.services.mediamtx-webrtc-ice.loadbalancer.server.port=8189
+ restart: unless-stopped
+
+volumes:
+ letsencrypt:
diff --git a/docker/mediamtx/mirror/mediamtx.yml b/docker/mediamtx/mirror/mediamtx.yml
new file mode 100644
index 0000000..995a86b
--- /dev/null
+++ b/docker/mediamtx/mirror/mediamtx.yml
@@ -0,0 +1,26 @@
+paths:
+ all:
+ source: publisher
+
+srt: yes
+srtAddress: :8890
+
+hls: yes
+hlsVariant: lowLatency
+hlsSegmentDuration: 2s
+hlsPartDuration: 1s
+hlsSegmentCount: 10
+
+webrtc: yes
+webrtcAddress: :8889
+webrtcLocalUDPAddress: :8189
+webrtcAdditionalHosts: []
+
+authMethod: http
+authHTTPAddress: https://hackclub.tv/api/mediamtx/publish
+
+api: yes
+apiAddress: :9997
+
+metrics: yes
+metricsAddress: :9998