feat(bs): initial browser streaming impl

This commit is contained in:
2026-04-21 22:06:19 +02:00
parent 5c4284d552
commit 0add39f8e1
4 changed files with 143 additions and 16 deletions

View File

@@ -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');
}

View File

@@ -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<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const publisherRef = useRef<MediaMTXWebRTCPublisher | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<video
ref={videoRef}
autoPlay
muted
playsInline
className="aspect-video w-full rounded-md bg-black"
/>
<div className="flex gap-2">
<Button onClick={startPublishing} disabled={isPublishing}>
Start
</Button>
<Button onClick={stopPublishing} disabled={!isPublishing}>
Stop
</Button>
</div>
{error ? <p>{error}</p> : null}
</div>
);
}
async function getPreferredVideoCodec(): Promise<string> {
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.'
);
}

View File

@@ -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<typeof setTimeout> | 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;

View File

@@ -45,7 +45,8 @@ export async function registerThumbnailWorker(): Promise<void> {
);
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) {