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 ( +
+
+ ); +} + +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({ )}
- - + - {error ?

{error}

: null} + {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/components/app/ChannelSelect/ChannelSelect.tsx b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx index ec1ddb8..48720b8 100644 --- a/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx +++ b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx @@ -1,8 +1,7 @@ -'use client' +'use client'; -import type { Channel } from "@hctv/db"; -import * as React from 'react'; import { Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { Select, SelectContent, @@ -11,13 +10,21 @@ import { SelectValue, } from '@/components/ui/select'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import type { Channel } from '@hctv/db'; export function ChannelSelect(props: Props) { - const { channelList } = props; + const { + channelList, + disabled = false, + includeCreate = false, + placeholder = 'Channel', + triggerClassName, + } = props; + return ( - + + {channelList.map((channel) => ( @@ -25,15 +32,22 @@ export function ChannelSelect(props: Props) {
- {channel.name[0]} + {channel.name[0]?.toUpperCase()}
{channel.name}
))} - } className='h-11'> - Create Channel - + {includeCreate ? ( + } + className="h-11" + > + Create Channel + + ) : null}
); @@ -42,5 +56,9 @@ export function ChannelSelect(props: Props) { interface Props { channelList: Channel[]; value?: string; + disabled?: boolean; + includeCreate?: boolean; onSelect: (value: string) => void; -} \ No newline at end of file + placeholder?: string; + triggerClassName?: string; +} diff --git a/apps/web/src/lib/hooks/useChannelStreamKey.ts b/apps/web/src/lib/hooks/useChannelStreamKey.ts new file mode 100644 index 0000000..16c55cd --- /dev/null +++ b/apps/web/src/lib/hooks/useChannelStreamKey.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useCallback } from 'react'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; + +interface StreamKeyResponse { + key: string; +} + +async function parseStreamKeyResponse(response: Response): Promise { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || 'Failed to load stream key'); + } + + return response.json(); +} + +async function fetchStreamKey( + [url, channelName]: readonly [string, string] +): Promise { + const response = await fetch(`${url}?channel=${encodeURIComponent(channelName)}`); + return parseStreamKeyResponse(response); +} + +async function regenerateStreamKey( + url: string, + { arg: channelName }: { arg: string } +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ channel: channelName }), + }); + + return parseStreamKeyResponse(response); +} + +export function useChannelStreamKey(channelName?: string, initialKey?: string | null) { + const swrKey = channelName ? (['/api/rtmp/streamKey', channelName] as const) : null; + const { data, error, isLoading, isValidating, mutate } = useSWR( + swrKey, + fetchStreamKey, + { + fallbackData: initialKey ? { key: initialKey } : undefined, + revalidateOnFocus: false, + } + ); + const { trigger, isMutating } = useSWRMutation('/api/rtmp/streamKey', regenerateStreamKey); + + const refreshStreamKey = useCallback(async () => { + if (!channelName) { + return undefined; + } + + return mutate(); + }, [channelName, mutate]); + + const handleRegenerateStreamKey = useCallback(async () => { + if (!channelName) { + throw new Error('Select a channel before regenerating its stream key'); + } + + const nextStreamKey = await trigger(channelName); + await mutate(nextStreamKey, { revalidate: false }); + return nextStreamKey.key; + }, [channelName, mutate, trigger]); + + return { + streamKey: data?.key ?? initialKey ?? '', + error, + isLoading, + isRefreshing: isValidating && !isLoading, + isRegenerating: isMutating, + refreshStreamKey, + regenerateStreamKey: handleRegenerateStreamKey, + }; +} diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts new file mode 100644 index 0000000..5000430 --- /dev/null +++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts @@ -0,0 +1,254 @@ +// completely generated by gpt-5.4 + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc'; + +const HLS_COMPATIBLE_VIDEO_CODECS = [ + ['h264', 'h264/90000'], + ['vp9', 'vp9/90000'], + ['av1', 'av1/90000'], + ['h265', 'h265/90000'], +] as const; + +const DISPLAY_MEDIA_OPTIONS: ScreenCaptureOptions = { + video: true, + audio: true, + monitorTypeSurfaces: 'include', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + systemAudio: 'include', +}; + +export function useScreensharePublisher({ + channelName, + streamKey, +}: UseScreensharePublisherOptions) { + const previewRef = useRef(null); + const captureStreamRef = useRef(null); + const captureCleanupRef = useRef<(() => void) | null>(null); + const publisherRef = useRef(null); + const [publishState, setPublishState] = useState('idle'); + const [error, setError] = useState(null); + + const setPreviewStream = useCallback((stream: MediaStream | null) => { + if (previewRef.current) { + previewRef.current.srcObject = stream; + } + }, []); + + const detachCaptureCleanup = useCallback(() => { + captureCleanupRef.current?.(); + captureCleanupRef.current = null; + }, []); + + const clearCaptureStream = useCallback(() => { + detachCaptureCleanup(); + stopTracks(captureStreamRef.current); + captureStreamRef.current = null; + setPreviewStream(null); + }, [detachCaptureCleanup, setPreviewStream]); + + const closePublisher = useCallback(() => { + const publisher = publisherRef.current; + + publisherRef.current = null; + publisher?.close(); + }, []); + + const disposeCurrentSession = useCallback(() => { + closePublisher(); + clearCaptureStream(); + }, [clearCaptureStream, closePublisher]); + + const stopPublishing = useCallback(() => { + disposeCurrentSession(); + setError(null); + setPublishState('idle'); + }, [disposeCurrentSession]); + + const attachCaptureStopListener = useCallback( + (stream: MediaStream) => { + const [videoTrack] = stream.getVideoTracks(); + + if (!videoTrack) { + captureCleanupRef.current = null; + return; + } + + const handleEnded = () => { + stopPublishing(); + }; + + videoTrack.addEventListener('ended', handleEnded); + captureCleanupRef.current = () => { + videoTrack.removeEventListener('ended', handleEnded); + }; + }, + [stopPublishing] + ); + + const commitCaptureStream = useCallback( + (nextStream: MediaStream) => { + const previousStream = captureStreamRef.current; + + detachCaptureCleanup(); + captureStreamRef.current = nextStream; + setPreviewStream(nextStream); + attachCaptureStopListener(nextStream); + stopTracks(previousStream); + }, + [attachCaptureStopListener, detachCaptureCleanup, setPreviewStream] + ); + + const startPublishing = useCallback(async () => { + if (!channelName) { + setError('Select a channel before starting your stream.'); + return; + } + + if (!streamKey) { + setError('Stream key unavailable for the selected channel.'); + return; + } + + try { + setError(null); + setPublishState('connecting'); + + const videoCodec = await getPreferredVideoCodec(); + const stream = await requestCaptureStream(); + + commitCaptureStream(stream); + + const publisher = new MediaMTXWebRTCPublisher({ + url: getWhipUrl(channelName), + stream, + videoCodec, + videoBitrate: 2000, + audioCodec: 'opus', + audioBitrate: 64, + audioVoice: true, + user: 'user', + pass: streamKey, + onConnected: () => { + if (publisherRef.current !== publisher) { + return; + } + + setPublishState('live'); + }, + onError: (message) => { + if (publisherRef.current !== publisher) { + return; + } + + setError(message); + setPublishState('connecting'); + }, + }); + + publisherRef.current = publisher; + } catch (err) { + disposeCurrentSession(); + setPublishState('idle'); + setError(getErrorMessage(err, 'Failed to start publishing')); + } + }, [channelName, commitCaptureStream, disposeCurrentSession, streamKey]); + + const changeSource = useCallback(async () => { + const publisher = publisherRef.current; + + if (!publisher) { + return; + } + + let nextStream: MediaStream | null = null; + + try { + setError(null); + setPublishState('switching'); + + nextStream = await requestCaptureStream(); + await publisher.replaceStream(nextStream); + commitCaptureStream(nextStream); + setPublishState('live'); + } catch (err) { + stopTracks(nextStream); + setPublishState(publisherRef.current ? 'live' : 'idle'); + setError(getErrorMessage(err, 'Failed to change screenshare source')); + } + }, [commitCaptureStream]); + + useEffect(() => { + return () => { + disposeCurrentSession(); + }; + }, [disposeCurrentSession]); + + return { + changeSource, + error, + isLive: publishState === 'live', + isSessionActive: publishState !== 'idle', + isStarting: publishState === 'connecting', + isSwitchingSource: publishState === 'switching', + previewRef, + startPublishing, + stopPublishing, + }; +} + +async function requestCaptureStream() { + return navigator.mediaDevices.getDisplayMedia(DISPLAY_MEDIA_OPTIONS as DisplayMediaStreamOptions); +} + +function getWhipUrl(channelName: string) { + return `http://localhost:8889/${encodeURIComponent(channelName)}/whip`; +} + +function stopTracks(stream: MediaStream | null) { + stream?.getTracks().forEach((track) => track.stop()); +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +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.' + ); +} + +type PublishState = 'idle' | 'connecting' | 'live' | 'switching'; + +type UseScreensharePublisherOptions = { + channelName: string; + streamKey?: string | null; +}; + +type ScreenCaptureOptions = DisplayMediaStreamOptions & { + monitorTypeSurfaces?: 'include' | 'exclude'; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; + systemAudio?: 'include' | 'exclude'; +}; diff --git a/apps/web/src/lib/utils/mediamtx/webrtc.ts b/apps/web/src/lib/utils/mediamtx/webrtc.ts index 68470f7..295dba0 100644 --- a/apps/web/src/lib/utils/mediamtx/webrtc.ts +++ b/apps/web/src/lib/utils/mediamtx/webrtc.ts @@ -1,9 +1,10 @@ // based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js -// modified by codex to typescript +// modified by codex to typescript and to suit the platform's needs! export type OnError = (err: string) => void; export type OnConnected = () => void; export type PublisherState = 'running' | 'restarting' | 'closed'; +type MediaKind = 'audio' | 'video'; export type PublisherConfig = { url: string; @@ -34,40 +35,85 @@ type ParsedIceServer = RTCIceServer & { export class MediaMTXWebRTCPublisher { private readonly retryPause = 2000; private readonly conf: PublisherConfig; + private stream: MediaStream; private state: PublisherState = 'running'; private restartTimeout: ReturnType | null = null; private pc: RTCPeerConnection | null = null; private offerData: OfferData | null = null; private sessionUrl: string | null = null; private queuedCandidates: RTCIceCandidate[] = []; + private trackSenders: Partial> = {}; constructor(conf: PublisherConfig) { if ( - typeof window === 'undefined' - || typeof RTCPeerConnection === 'undefined' - || typeof MediaStream === 'undefined' + typeof window === 'undefined' || + typeof RTCPeerConnection === 'undefined' || + typeof MediaStream === 'undefined' ) { - throw new Error( - 'MediaMTXWebRTCPublisher can only be used in a browser environment.' - ); + throw new Error('MediaMTXWebRTCPublisher can only be used in a browser environment.'); } this.conf = conf; + this.stream = conf.stream; this.start(); } close = (): void => { this.state = 'closed'; - if (this.pc !== null) { - this.pc.close(); - } - if (this.restartTimeout !== null) { clearTimeout(this.restartTimeout); } + + this.resetConnection(); + this.disposeSession(); }; + replaceStream = async (stream: MediaStream): Promise => { + if (this.state !== 'running' || this.pc === null) { + throw new Error('publisher is not running'); + } + + const nextTracks: Record = { + audio: stream.getAudioTracks()[0] ?? null, + video: stream.getVideoTracks()[0] ?? null, + }; + + await Promise.all( + (['audio', 'video'] as const).map(async (kind) => { + const sender = this.trackSenders[kind]; + + if (!sender) { + return; + } + + await sender.replaceTrack(nextTracks[kind]); + }) + ); + + this.stream = stream; + }; + + private resetConnection(): void { + if (this.pc !== null) { + this.pc.close(); + this.pc = null; + } + + this.offerData = null; + this.queuedCandidates = []; + this.trackSenders = {}; + } + + private disposeSession(): void { + if (this.sessionUrl !== null) { + void fetch(this.sessionUrl, { + method: 'DELETE', + }); + this.sessionUrl = null; + } + } + static #unquoteCredential(value: string): string { return JSON.parse(`"${value}"`) as string; } @@ -120,10 +166,7 @@ export class MediaMTXWebRTCPublisher { return parsedOffer; } - static #generateSdpFragment( - offerData: OfferData, - candidates: RTCIceCandidate[] - ): string { + static #generateSdpFragment(offerData: OfferData, candidates: RTCIceCandidate[]): string { const candidatesByMedia: Record = {}; for (const candidate of candidates) { @@ -138,15 +181,13 @@ export class MediaMTXWebRTCPublisher { candidatesByMedia[mid].push(candidate); } - let fragment = `a=ice-ufrag:${offerData.iceUfrag}\r\n` - + `a=ice-pwd:${offerData.icePwd}\r\n`; + 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`; + fragment += `m=${media}\r\n` + `a=mid:${mid}\r\n`; for (const candidate of candidatesByMedia[mid]) { fragment += `a=${candidate.candidate}\r\n`; @@ -292,21 +333,8 @@ export class MediaMTXWebRTCPublisher { 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.resetConnection(); + this.disposeSession(); this.state = 'restarting'; this.restartTimeout = setTimeout(() => { @@ -352,9 +380,14 @@ export class MediaMTXWebRTCPublisher { this.pc.onicecandidate = (event) => this.onLocalCandidate(event); this.pc.onconnectionstatechange = () => this.onConnectionState(); + this.trackSenders = {}; - this.conf.stream.getTracks().forEach((track) => { - this.pc?.addTrack(track, this.conf.stream); + this.stream.getTracks().forEach((track) => { + const sender = this.pc?.addTrack(track, this.stream); + + if (sender && (track.kind === 'audio' || track.kind === 'video')) { + this.trackSenders[track.kind] = sender; + } }); const offer = await this.pc.createOffer(); @@ -421,10 +454,7 @@ export class MediaMTXWebRTCPublisher { throw new Error('missing peer connection'); } - const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer( - answer, - this.conf.videoBitrate - ); + const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer(answer, this.conf.videoBitrate); await peerConnection.setRemoteDescription( new RTCSessionDescription({ @@ -490,10 +520,7 @@ export class MediaMTXWebRTCPublisher { return; } - if ( - this.pc.connectionState === 'failed' - || this.pc.connectionState === 'closed' - ) { + if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') { this.handleError('peer connection closed'); } else if (this.pc.connectionState === 'connected') { this.conf.onConnected?.(); From da968232adb0c447692e4a2be1e21e42267a2b4a Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:27:42 +0200 Subject: [PATCH 4/8] feat(bs): server selector --- .../src/app/(ui)/(protected)/stream/page.tsx | 37 ++++++++++++++++++- .../src/lib/hooks/useScreensharePublisher.ts | 16 +++++--- apps/web/src/lib/utils/mediamtx/client.ts | 17 +++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx index d2a8be6..98d18e0 100644 --- a/apps/web/src/app/(ui)/(protected)/stream/page.tsx +++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx @@ -3,12 +3,25 @@ import { useEffect, useState } from 'react'; import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey'; import { useOwnedChannels } from '@/lib/hooks/useUserList'; import { useScreensharePublisher } from '@/lib/hooks/useScreensharePublisher'; +import { getMediamtxClientRegionOptions } from '@/lib/utils/mediamtx/client'; +import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions'; export default function Page() { + const serverOptions = getMediamtxClientRegionOptions(); const [selectedChannel, setSelectedChannel] = useState(''); + const [selectedRegion, setSelectedRegion] = useState( + serverOptions[0]?.value ?? 'hq' + ); const { channels, isLoading: isLoadingChannels } = useOwnedChannels(); const ownedChannels = channels.map(({ channel }) => channel); const { @@ -28,10 +41,12 @@ export default function Page() { stopPublishing, } = useScreensharePublisher({ channelName: selectedChannel, + region: selectedRegion, streamKey, }); const hasChannels = ownedChannels.length > 0; + const hasServerOptions = serverOptions.length > 0; const canStartPublishing = !isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey; const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel'; @@ -53,7 +68,7 @@ export default function Page() { broadcast.

-
+

Channel

+ +
+

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' ? ( + + ) : null} + + {primaryIssue.context === 'switch' && isLive ? ( + + ) : null} + + {isSessionActive && primaryIssue.context !== 'warning' ? ( + + ) : 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 */} +
+
+
+
+
- {!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' ? ( + + ) : null} + + {!isSessionActive && + primaryIssue.context !== 'warning' && + primaryIssue.context !== 'preview' ? ( + + ) : null} + + {primaryIssue.context === 'switch' && isLive ? ( + + ) : null} + + {isSessionActive && primaryIssue.context !== 'warning' ? ( + + ) : 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' ? ( - - ) : null} - - {primaryIssue.context === 'switch' && isLive ? ( + {/* Right: Actions */} +
+ {!isSessionActive ? ( +
- ) : null} - {isSessionActive && primaryIssue.context !== 'warning' ? ( - + ) : null} + + +
+ ) : ( +
+ - ) : null} - - } - description={primaryIssue.description} - icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert} - title={primaryIssue.title} - tone={primaryIssue.tone} - /> - ) : null} -
+
); } -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 && ( + + + + )} + {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