feat(bs): select channels and switch sources

This commit is contained in:
2026-04-22 20:35:11 +02:00
parent 0add39f8e1
commit be758685d1
7 changed files with 618 additions and 191 deletions

View File

@@ -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<AuthorizedChannelResult> {
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;
};
}
});
}
| {
response: Response;
};

View File

@@ -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<string | null>(null);
const [region, setRegion] = useState<MediaMTXRegion>('hq');
const channelList = useOwnedChannels();
const {
streamKey,
isRegenerating: isRegeneratingStreamKey,
regenerateStreamKey,
} = useChannelStreamKey(channel.name, channel.streamKey?.key);
const router = useRouter();
const channelSettingsFormRef = useRef<HTMLFormElement>(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({
<div>
<ChannelSelect
channelList={channelList.channels.map((c) => c.channel)}
includeCreate
value={channel.name}
onSelect={(value) => {
if (value === 'create') {
@@ -561,7 +556,12 @@ export default function ChannelSettingsClient({
)}
</button>
</div>
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
<Button
onClick={handleRegenerateStreamKey}
variant="outline"
size="smicon"
loading={isRegeneratingStreamKey}
>
<Key className="h-4 w-4" />
</Button>
<Button

View File

@@ -1,80 +1,82 @@
'use client';
import { useRef, useState } from 'react';
import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc';
import { useEffect, useState } from 'react';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
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;
import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey';
import { useOwnedChannels } from '@/lib/hooks/useUserList';
import { useScreensharePublisher } from '@/lib/hooks/useScreensharePublisher';
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 [selectedChannel, setSelectedChannel] = useState('');
const { channels, isLoading: isLoadingChannels } = useOwnedChannels();
const ownedChannels = channels.map(({ channel }) => channel);
const {
streamKey,
error: streamKeyError,
isLoading: isLoadingStreamKey,
} = useChannelStreamKey(selectedChannel || undefined);
const {
changeSource,
error,
isLive,
isSessionActive,
isStarting,
isSwitchingSource,
previewRef,
startPublishing,
stopPublishing,
} = useScreensharePublisher({
channelName: selectedChannel,
streamKey,
});
const startPublishing = async () => {
try {
setError(null);
const videoCodec = await getPreferredVideoCodec();
const hasChannels = ownedChannels.length > 0;
const canStartPublishing =
!isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
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;
useEffect(() => {
if (isSessionActive) {
return;
}
setIsPublishing(false);
};
if (!ownedChannels.some((channel) => channel.name === selectedChannel)) {
setSelectedChannel(ownedChannels[0]?.name ?? '');
}
}, [isSessionActive, ownedChannels, selectedChannel]);
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Start a screenshare stream, then switch windows, tabs, or displays without ending the
broadcast.
</p>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="space-y-2">
<p className="text-sm font-medium">Channel</p>
<ChannelSelect
channelList={ownedChannels}
disabled={isSessionActive || isLoadingChannels || !hasChannels}
placeholder={channelPlaceholder}
value={selectedChannel || undefined}
onSelect={setSelectedChannel}
triggerClassName="w-full"
/>
</div>
</div>
{!hasChannels && !isLoadingChannels ? (
<p className="text-sm text-muted-foreground">
You need at least one channel before you can publish.
</p>
) : null}
{streamKeyError ? <p className="text-sm text-destructive">{streamKeyError.message}</p> : null}
<video
ref={videoRef}
ref={previewRef}
autoPlay
muted
playsInline
@@ -82,38 +84,23 @@ export default function Page() {
/>
<div className="flex gap-2">
<Button onClick={startPublishing} disabled={isPublishing}>
<Button onClick={startPublishing} disabled={!canStartPublishing} loading={isStarting}>
Start
</Button>
<Button onClick={stopPublishing} disabled={!isPublishing}>
<Button
variant="outline"
onClick={changeSource}
disabled={!isLive}
loading={isSwitchingSource}
>
Change source
</Button>
<Button onClick={stopPublishing} disabled={!isSessionActive || isSwitchingSource}>
Stop
</Button>
</div>
{error ? <p>{error}</p> : null}
{error ? <p className="text-sm text-destructive">{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,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 (
<Select onValueChange={props.onSelect} value={props.value}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Channel" />
<Select disabled={disabled} onValueChange={props.onSelect} value={props.value}>
<SelectTrigger className={cn('w-[180px]', triggerClassName)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{channelList.map((channel) => (
@@ -25,15 +32,22 @@ export function ChannelSelect(props: Props) {
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
<AvatarFallback>{channel.name[0]}</AvatarFallback>
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="font-medium">{channel.name}</div>
</div>
</SelectItem>
))}
<SelectItem key="create" value="create" icon={<Plus className="h-4 w-4" />} className='h-11'>
Create Channel
</SelectItem>
{includeCreate ? (
<SelectItem
key="create"
value="create"
icon={<Plus className="h-4 w-4" />}
className="h-11"
>
Create Channel
</SelectItem>
) : null}
</SelectContent>
</Select>
);
@@ -42,5 +56,9 @@ export function ChannelSelect(props: Props) {
interface Props {
channelList: Channel[];
value?: string;
disabled?: boolean;
includeCreate?: boolean;
onSelect: (value: string) => void;
}
placeholder?: string;
triggerClassName?: string;
}

View File

@@ -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<StreamKeyResponse> {
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<StreamKeyResponse> {
const response = await fetch(`${url}?channel=${encodeURIComponent(channelName)}`);
return parseStreamKeyResponse(response);
}
async function regenerateStreamKey(
url: string,
{ arg: channelName }: { arg: string }
): Promise<StreamKeyResponse> {
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<StreamKeyResponse>(
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,
};
}

View File

@@ -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<HTMLVideoElement>(null);
const captureStreamRef = useRef<MediaStream | null>(null);
const captureCleanupRef = useRef<(() => void) | null>(null);
const publisherRef = useRef<MediaMTXWebRTCPublisher | null>(null);
const [publishState, setPublishState] = useState<PublishState>('idle');
const [error, setError] = useState<string | null>(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<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.'
);
}
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';
};

View File

@@ -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<typeof setTimeout> | null = null;
private pc: RTCPeerConnection | null = null;
private offerData: OfferData | null = null;
private sessionUrl: string | null = null;
private queuedCandidates: RTCIceCandidate[] = [];
private trackSenders: Partial<Record<MediaKind, RTCRtpSender>> = {};
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<void> => {
if (this.state !== 'running' || this.pc === null) {
throw new Error('publisher is not running');
}
const nextTracks: Record<MediaKind, MediaStreamTrack | null> = {
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<number, RTCIceCandidate[]> = {};
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?.();