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] 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) {