mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat(bs): select channels and switch sources
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
81
apps/web/src/lib/hooks/useChannelStreamKey.ts
Normal file
81
apps/web/src/lib/hooks/useChannelStreamKey.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
254
apps/web/src/lib/hooks/useScreensharePublisher.ts
Normal file
254
apps/web/src/lib/hooks/useScreensharePublisher.ts
Normal 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';
|
||||
};
|
||||
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user