mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat(bs): initial browser streaming impl
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
119
apps/web/src/app/(ui)/(protected)/stream/page.tsx
Normal file
119
apps/web/src/app/(ui)/(protected)/stream/page.tsx
Normal 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.'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user