mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Merge pull request #66 from SrIzan10/feat/browser-streaming
feat: #68 feat/browser streaming
This commit is contained in:
@@ -21,11 +21,13 @@ HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback
|
||||
NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891
|
||||
MEDIAMTX_API_HQ=http://localhost:9997
|
||||
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
|
||||
NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ=http://localhost:8889
|
||||
|
||||
# commented because we don't have another ingest server as of right now
|
||||
# NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991
|
||||
# MEDIAMTX_API_ASIA=http://localhost:9999
|
||||
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990
|
||||
|
||||
# idt you should change this
|
||||
MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge
|
||||
# generate with `openssl rand -base64 20`
|
||||
MEDIAMTX_PUBLISH_KEY=
|
||||
MEDIAMTX_API_KEY=
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -79,6 +80,13 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
return finish('authorized', 200, 'authorized_read');
|
||||
}
|
||||
if (parsedAction === 'api') {
|
||||
if (password === process.env.MEDIAMTX_API_KEY) {
|
||||
return finish('authorized api', 200, 'authorized_api');
|
||||
}
|
||||
|
||||
return finish('unauthorized api', 401, 'unauthorized_api');
|
||||
}
|
||||
|
||||
return finish('uhh', 401, 'unauthorized');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
396
apps/web/src/app/(ui)/(protected)/stream/page.tsx
Normal file
396
apps/web/src/app/(ui)/(protected)/stream/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CircleAlert,
|
||||
Globe,
|
||||
LoaderCircle,
|
||||
Monitor,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Square,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey';
|
||||
import { useOwnedChannels } from '@/lib/hooks/useUserList';
|
||||
import { useScreensharePublisher } from '@/lib/hooks/useScreensharePublisher';
|
||||
import { getMediamtxClientRegionOptions } from '@/lib/utils/mediamtx/client';
|
||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||
|
||||
export default function Page() {
|
||||
const serverOptions = getMediamtxClientRegionOptions();
|
||||
const [selectedChannel, setSelectedChannel] = useState('');
|
||||
const [selectedRegion, setSelectedRegion] = useState<MediaMTXRegion>(
|
||||
serverOptions[0]?.value ?? 'hq'
|
||||
);
|
||||
const { channels, isLoading: isLoadingChannels } = useOwnedChannels();
|
||||
const ownedChannels = channels.map(({ channel }) => channel);
|
||||
const {
|
||||
streamKey,
|
||||
error: streamKeyError,
|
||||
isLoading: isLoadingStreamKey,
|
||||
} = useChannelStreamKey(selectedChannel || undefined);
|
||||
const {
|
||||
browserWarning,
|
||||
changeSource,
|
||||
hasPreview,
|
||||
issue,
|
||||
isLive,
|
||||
isPreviewReady,
|
||||
isPreviewingSource,
|
||||
isSessionActive,
|
||||
isStarting,
|
||||
isSwitchingSource,
|
||||
previewRef,
|
||||
previewSource,
|
||||
startPublishing,
|
||||
stopPublishing,
|
||||
} = useScreensharePublisher({
|
||||
channelName: selectedChannel,
|
||||
region: selectedRegion,
|
||||
streamKey,
|
||||
});
|
||||
|
||||
const hasChannels = ownedChannels.length > 0;
|
||||
const hasServerOptions = serverOptions.length > 0;
|
||||
const canStartPublishing =
|
||||
!isSessionActive &&
|
||||
!isPreviewingSource &&
|
||||
Boolean(selectedChannel) &&
|
||||
Boolean(streamKey) &&
|
||||
!isLoadingStreamKey;
|
||||
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
|
||||
const primaryIssue = issue ?? browserWarning;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSessionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ownedChannels.some((channel) => channel.name === selectedChannel)) {
|
||||
setSelectedChannel(ownedChannels[0]?.name ?? '');
|
||||
}
|
||||
}, [isSessionActive, ownedChannels, selectedChannel]);
|
||||
|
||||
const statusLabel = isLive
|
||||
? 'LIVE'
|
||||
: isSwitchingSource
|
||||
? 'Switching'
|
||||
: isStarting
|
||||
? 'Connecting'
|
||||
: isPreviewingSource
|
||||
? hasPreview
|
||||
? 'Updating Preview'
|
||||
: 'Preparing Preview'
|
||||
: isPreviewReady
|
||||
? 'Preview'
|
||||
: 'Ready';
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[calc(100vh-4rem)] flex-col">
|
||||
{/* Video Stage */}
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-4 md:px-6">
|
||||
<div className="w-full max-w-6xl">
|
||||
<div className="relative overflow-hidden rounded-3xl border border-border/60 bg-black shadow-2xl">
|
||||
<div className="relative aspect-video w-full bg-black">
|
||||
<video
|
||||
ref={previewRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
|
||||
{!hasPreview && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-5 px-6 text-muted-foreground">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-secondary bg-secondary/80">
|
||||
<Monitor className="h-10 w-10 text-primary/80" />
|
||||
</div>
|
||||
<div className="max-w-md text-center space-y-1.5">
|
||||
<p className="text-lg font-medium text-zinc-200">
|
||||
Ready to livestream
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Select a tab, window, or display to preview.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isPreviewingSource || isStarting || isSwitchingSource) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/60 text-white backdrop-blur-sm">
|
||||
<LoaderCircle className="h-8 w-8 animate-spin" />
|
||||
<p className="text-sm font-medium">
|
||||
{isPreviewingSource
|
||||
? hasPreview
|
||||
? 'Updating preview...'
|
||||
: 'Preparing preview...'
|
||||
: isStarting
|
||||
? 'Starting broadcast...'
|
||||
: 'Switching source...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute left-6 top-6">
|
||||
<Badge
|
||||
variant={isLive ? 'default' : hasPreview ? 'secondary' : 'outline'}
|
||||
className={cn(
|
||||
'gap-2 px-3 py-1 text-xs font-semibold shadow-lg backdrop-blur-md transition-all',
|
||||
isLive && 'bg-red-500 text-white hover:bg-red-600',
|
||||
!isLive && !hasPreview && 'border-zinc-800 bg-black/50 text-zinc-400'
|
||||
)}
|
||||
>
|
||||
{isLive && <span className="h-2 w-2 animate-pulse rounded-full bg-white shadow-[0_0_8px_rgba(255,255,255,0.8)]" />}
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(streamKeyError || primaryIssue) && (
|
||||
<div className="absolute inset-x-0 top-4 z-10 mx-auto max-w-xl px-4 md:top-6">
|
||||
{streamKeyError ? (
|
||||
<AlertCard
|
||||
actions={
|
||||
<Button onClick={() => window.location.reload()} size="sm" variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload page
|
||||
</Button>
|
||||
}
|
||||
description={getStreamKeyErrorDescription(streamKeyError.message)}
|
||||
icon={CircleAlert}
|
||||
title="Could not load the stream key"
|
||||
tone="destructive"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{primaryIssue ? (
|
||||
<AlertCard
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!isSessionActive && primaryIssue.context === 'preview' ? (
|
||||
<Button
|
||||
onClick={previewSource}
|
||||
disabled={isPreviewingSource}
|
||||
loading={isPreviewingSource}
|
||||
size="sm"
|
||||
>
|
||||
Preview again
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!isSessionActive &&
|
||||
primaryIssue.context !== 'warning' &&
|
||||
primaryIssue.context !== 'preview' ? (
|
||||
<Button onClick={startPublishing} disabled={!canStartPublishing} size="sm">
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{primaryIssue.context === 'switch' && isLive ? (
|
||||
<Button
|
||||
onClick={changeSource}
|
||||
disabled={isSwitchingSource}
|
||||
loading={isSwitchingSource}
|
||||
size="sm"
|
||||
>
|
||||
Try switching again
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSessionActive && primaryIssue.context !== 'warning' ? (
|
||||
<Button onClick={stopPublishing} size="sm" variant="outline">
|
||||
Stop stream
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
description={primaryIssue.description}
|
||||
icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
|
||||
title={primaryIssue.title}
|
||||
tone={primaryIssue.tone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-border/50 bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-stretch gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Video className="h-4 w-4 text-muted-foreground" />
|
||||
<ChannelSelect
|
||||
channelList={ownedChannels}
|
||||
disabled={isSessionActive || isLoadingChannels || !hasChannels}
|
||||
placeholder={channelPlaceholder}
|
||||
value={selectedChannel || undefined}
|
||||
onSelect={setSelectedChannel}
|
||||
triggerClassName="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={selectedRegion}
|
||||
onValueChange={(value) => setSelectedRegion(value as MediaMTXRegion)}
|
||||
disabled={isSessionActive || !hasServerOptions}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Select server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverOptions.map((server) => (
|
||||
<SelectItem
|
||||
key={server.value}
|
||||
value={server.value}
|
||||
disabled={!server.whipEnabled}
|
||||
>
|
||||
{server.label} {server.emoji}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!hasChannels && !isLoadingChannels ? (
|
||||
<p className="text-xs text-muted-foreground">Create a channel to stream.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{!isSessionActive ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={previewSource}
|
||||
disabled={isPreviewingSource}
|
||||
loading={isPreviewingSource}
|
||||
size="default"
|
||||
>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
{hasPreview ? 'Change Preview' : 'Preview'}
|
||||
</Button>
|
||||
|
||||
{hasPreview ? (
|
||||
<Button
|
||||
onClick={stopPublishing}
|
||||
disabled={isPreviewingSource}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Clear Preview
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={startPublishing}
|
||||
disabled={!canStartPublishing || isSwitchingSource}
|
||||
loading={isStarting}
|
||||
size="default"
|
||||
>
|
||||
<Radio className="mr-2 h-4 w-4" />
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={changeSource}
|
||||
disabled={!isLive}
|
||||
loading={isSwitchingSource}
|
||||
size="default"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Switch
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={stopPublishing}
|
||||
disabled={isPreviewingSource || isSwitchingSource}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCard({ actions, description, icon: Icon, title, tone }: AlertCardProps) {
|
||||
const isWarning = tone === 'warning';
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'overflow-hidden border-l-4 shadow-xl backdrop-blur-md',
|
||||
isWarning
|
||||
? 'border-l-amber-500 bg-amber-500/[0.03]'
|
||||
: 'border-l-destructive bg-destructive/[0.03]'
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 h-5 w-5 shrink-0',
|
||||
isWarning ? 'text-amber-500' : 'text-destructive'
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2 md:shrink-0">{actions}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getStreamKeyErrorDescription(message: string) {
|
||||
if (message.toLowerCase().includes('unauthorized')) {
|
||||
return 'You no longer have permission to stream to this channel. Try another channel or sign in again.';
|
||||
}
|
||||
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return 'This channel does not have a valid stream key yet. Regenerate it in channel settings, then retry.';
|
||||
}
|
||||
|
||||
return 'Refresh the page and try again. If it keeps failing, check channel settings and server config.';
|
||||
}
|
||||
|
||||
type AlertCardProps = {
|
||||
actions?: ReactNode;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
tone: 'warning' | 'destructive';
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,18 @@ import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { IdCard, Shield, Settings, Users, PenSquare, LogOut, Code, Github, Heart } from 'lucide-react';
|
||||
import {
|
||||
IdCard,
|
||||
Shield,
|
||||
Settings,
|
||||
Users,
|
||||
PenSquare,
|
||||
LogOut,
|
||||
Code,
|
||||
Github,
|
||||
Heart,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Image from 'next/image';
|
||||
import Logo from '@/lib/assets/logo.webp';
|
||||
@@ -52,6 +63,16 @@ export default function Navbar(props: Props) {
|
||||
|
||||
{/* Right Side Items */}
|
||||
<div className="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
{user && (
|
||||
<Link href="/stream">
|
||||
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
|
||||
<Radio className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Go live</span>
|
||||
<span className="sm:hidden">Live</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
|
||||
|
||||
{user ? (
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
440
apps/web/src/lib/hooks/useScreensharePublisher.ts
Normal file
440
apps/web/src/lib/hooks/useScreensharePublisher.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||
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,
|
||||
region,
|
||||
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 [hasPreview, setHasPreview] = useState(false);
|
||||
const [issue, setIssue] = useState<PublisherIssue | null>(null);
|
||||
const browserWarning = useMemo(() => getBrowserWarning(), []);
|
||||
|
||||
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;
|
||||
setHasPreview(false);
|
||||
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();
|
||||
setIssue(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;
|
||||
setHasPreview(true);
|
||||
setPreviewStream(nextStream);
|
||||
attachCaptureStopListener(nextStream);
|
||||
stopTracks(previousStream);
|
||||
},
|
||||
[attachCaptureStopListener, detachCaptureCleanup, setPreviewStream]
|
||||
);
|
||||
|
||||
const previewSource = useCallback(async () => {
|
||||
try {
|
||||
setIssue(null);
|
||||
setPublishState('previewing');
|
||||
|
||||
const stream = await requestCaptureStream();
|
||||
|
||||
commitCaptureStream(stream);
|
||||
setPublishState('preview');
|
||||
} catch (err) {
|
||||
setPublishState(captureStreamRef.current ? 'preview' : 'idle');
|
||||
setIssue(classifyPublisherIssue(err, 'preview'));
|
||||
}
|
||||
}, [commitCaptureStream]);
|
||||
|
||||
const startPublishing = useCallback(async () => {
|
||||
if (!channelName) {
|
||||
setIssue({
|
||||
context: 'start',
|
||||
description: 'Pick a channel first so we know where to publish.',
|
||||
title: 'Choose a channel before starting',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamKey) {
|
||||
setIssue({
|
||||
context: 'start',
|
||||
description: 'Wait for the stream key to load, then try starting again.',
|
||||
title: 'Stream key is still unavailable',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIssue(null);
|
||||
setPublishState('connecting');
|
||||
|
||||
const videoCodec = await getPreferredVideoCodec();
|
||||
let stream = captureStreamRef.current;
|
||||
|
||||
if (!stream) {
|
||||
stream = await requestCaptureStream();
|
||||
commitCaptureStream(stream);
|
||||
}
|
||||
|
||||
const publisher = new MediaMTXWebRTCPublisher({
|
||||
url: getWhipUrl(channelName, region),
|
||||
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;
|
||||
}
|
||||
|
||||
setIssue(classifyPublisherIssue(message, 'publish'));
|
||||
setPublishState('connecting');
|
||||
},
|
||||
});
|
||||
|
||||
publisherRef.current = publisher;
|
||||
} catch (err) {
|
||||
closePublisher();
|
||||
setPublishState(captureStreamRef.current ? 'preview' : 'idle');
|
||||
setIssue(classifyPublisherIssue(err, 'start'));
|
||||
}
|
||||
}, [channelName, closePublisher, commitCaptureStream, region, streamKey]);
|
||||
|
||||
const changeSource = useCallback(async () => {
|
||||
const publisher = publisherRef.current;
|
||||
|
||||
if (!publisher) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextStream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
setIssue(null);
|
||||
setPublishState('switching');
|
||||
|
||||
nextStream = await requestCaptureStream();
|
||||
await publisher.replaceStream(nextStream);
|
||||
commitCaptureStream(nextStream);
|
||||
setPublishState('live');
|
||||
} catch (err) {
|
||||
stopTracks(nextStream);
|
||||
setPublishState(publisherRef.current ? 'live' : 'idle');
|
||||
setIssue(classifyPublisherIssue(err, 'switch'));
|
||||
}
|
||||
}, [commitCaptureStream]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeCurrentSession();
|
||||
};
|
||||
}, [disposeCurrentSession]);
|
||||
|
||||
return {
|
||||
browserWarning,
|
||||
changeSource,
|
||||
hasPreview,
|
||||
issue,
|
||||
isLive: publishState === 'live',
|
||||
isPreviewReady: publishState === 'preview',
|
||||
isPreviewingSource: publishState === 'previewing',
|
||||
isSessionActive:
|
||||
publishState === 'connecting' || publishState === 'live' || publishState === 'switching',
|
||||
isStarting: publishState === 'connecting',
|
||||
isSwitchingSource: publishState === 'switching',
|
||||
publishState,
|
||||
previewRef,
|
||||
previewSource,
|
||||
startPublishing,
|
||||
stopPublishing,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCaptureStream() {
|
||||
return navigator.mediaDevices.getDisplayMedia(DISPLAY_MEDIA_OPTIONS as DisplayMediaStreamOptions);
|
||||
}
|
||||
|
||||
function getWhipUrl(channelName: string, region: MediaMTXRegion) {
|
||||
const { whip } = getMediamtxClientEnvs(region);
|
||||
|
||||
return `${whip.replace(/\/$/, '')}/${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;
|
||||
}
|
||||
|
||||
function classifyPublisherIssue(error: unknown, context: PublisherIssueContext): PublisherIssue {
|
||||
const message = getErrorMessage(
|
||||
error,
|
||||
context === 'switch'
|
||||
? 'Failed to change screenshare source'
|
||||
: context === 'preview'
|
||||
? 'Failed to preview the selected source'
|
||||
: 'Failed to start publishing'
|
||||
);
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
|
||||
if (normalizedMessage.includes('notallowederror') || normalizedMessage.includes('permission')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
context === 'switch'
|
||||
? 'Choose a new tab, window, or display in the browser picker to continue the broadcast.'
|
||||
: context === 'preview'
|
||||
? 'Approve the browser screen-share prompt so we can load your preview.'
|
||||
: 'Approve the browser screen-share prompt, then try again.',
|
||||
title:
|
||||
context === 'switch'
|
||||
? 'Source switch was cancelled or blocked'
|
||||
: context === 'preview'
|
||||
? 'Preview permission was denied'
|
||||
: 'Screen-share permission was denied',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMessage.includes('notfounderror')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'Open the window or tab you want to capture, then retry the screen-share picker.',
|
||||
title: 'No capturable source was found',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedMessage.includes('getdisplaymedia') ||
|
||||
normalizedMessage.includes('secure context') ||
|
||||
normalizedMessage.includes('browser environment')
|
||||
) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'Use HackClub.tv over HTTPS or localhost in a Chromium-based browser, then try again.',
|
||||
title: 'This browser or page cannot start screen sharing',
|
||||
tone: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMessage.includes('hls-compatible webrtc video codec')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'Switch to a Chromium-based browser. Firefox and Safari can expose codecs that our ingest pipeline cannot use reliably yet.',
|
||||
title: 'This browser cannot publish a compatible stream codec',
|
||||
tone: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMessage.includes('invalid stream key') || normalizedMessage.includes('403')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'Refresh the page or regenerate the stream key in channel settings if this keeps happening.',
|
||||
title: 'The ingest server rejected your stream key',
|
||||
tone: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMessage.includes('404')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'The selected ingest server may be misconfigured or offline. Try another server or retry in a moment.',
|
||||
title: 'The selected ingest server could not be reached',
|
||||
tone: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMessage.includes('retrying in some seconds')) {
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
'We are retrying automatically. Keep this page open, or stop and start again if it does not recover.',
|
||||
title: 'Connection to the ingest server dropped',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
description:
|
||||
context === 'switch'
|
||||
? 'Try choosing the source again. If it keeps failing, stop the stream and start a new session.'
|
||||
: context === 'preview'
|
||||
? 'Try choosing the source again. If it keeps failing, reload the page or switch browsers.'
|
||||
: 'Try again. If it keeps failing, switch servers or reload the page.',
|
||||
title:
|
||||
context === 'switch'
|
||||
? 'Could not switch the shared source'
|
||||
: context === 'preview'
|
||||
? 'Could not load the preview'
|
||||
: 'Could not start the stream',
|
||||
tone: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
function getBrowserWarning(): PublisherIssue | null {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isChromium =
|
||||
userAgent.includes('chrome') || userAgent.includes('chromium') || userAgent.includes('edg/');
|
||||
|
||||
if (isChromium) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
context: 'warning',
|
||||
description:
|
||||
'You can still try this here, but screen capture and source switching are most reliable in Chrome or another Chromium-based browser.',
|
||||
title: 'This browser is supported on a best-effort basis',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
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' | 'previewing' | 'preview' | 'connecting' | 'live' | 'switching';
|
||||
|
||||
type UseScreensharePublisherOptions = {
|
||||
channelName: string;
|
||||
region: MediaMTXRegion;
|
||||
streamKey?: string | null;
|
||||
};
|
||||
|
||||
type PublisherIssue = {
|
||||
context: PublisherIssueContext;
|
||||
description: string;
|
||||
title: string;
|
||||
tone: 'warning' | 'destructive';
|
||||
};
|
||||
|
||||
type PublisherIssueContext = 'preview' | 'publish' | 'start' | 'switch' | 'warning';
|
||||
|
||||
type ScreenCaptureOptions = DisplayMediaStreamOptions & {
|
||||
monitorTypeSurfaces?: 'include' | 'exclude';
|
||||
selfBrowserSurface?: 'include' | 'exclude';
|
||||
surfaceSwitching?: 'include' | 'exclude';
|
||||
systemAudio?: 'include' | 'exclude';
|
||||
};
|
||||
@@ -90,7 +90,15 @@ export async function syncStream() {
|
||||
|
||||
for (const r of regions) {
|
||||
const region = MEDIAMTX_SERVER_REGIONS[r];
|
||||
const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`);
|
||||
if (!region.apiAuthHeader) {
|
||||
throw new Error('MEDIAMTX_API_KEY is required when querying the MediaMTX API');
|
||||
}
|
||||
|
||||
const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`, {
|
||||
headers: {
|
||||
Authorization: region.apiAuthHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
recordStreamSyncScrape(r, 'error');
|
||||
|
||||
@@ -4,18 +4,37 @@ import { getEnv } from '@/lib/env';
|
||||
export interface MediaMTXClientEnvs {
|
||||
publicUrl: string;
|
||||
ingestRoute: string;
|
||||
whip: string;
|
||||
whipEnabled: boolean;
|
||||
emoji: string;
|
||||
string: string;
|
||||
}
|
||||
|
||||
export interface MediaMTXClientRegionOption {
|
||||
value: MediaMTXRegion;
|
||||
emoji: string;
|
||||
label: string;
|
||||
whipEnabled: boolean;
|
||||
}
|
||||
|
||||
export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs {
|
||||
const envs: Record<MediaMTXRegion, MediaMTXClientEnvs> = {
|
||||
hq: {
|
||||
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!,
|
||||
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!,
|
||||
whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ')!,
|
||||
whipEnabled: false,
|
||||
emoji: '🇺🇸',
|
||||
string: 'HQ Server A',
|
||||
},
|
||||
ethande: {
|
||||
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_ETHANDE')!,
|
||||
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ETHANDE')!,
|
||||
whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_ETHANDE')!,
|
||||
whipEnabled: true,
|
||||
emoji: '🇩🇪',
|
||||
string: 'eth0\'s VPS',
|
||||
},
|
||||
};
|
||||
|
||||
const regionEnvs = envs[region];
|
||||
@@ -27,3 +46,13 @@ export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXCl
|
||||
return regionEnvs;
|
||||
}
|
||||
|
||||
export function getMediamtxClientRegionOptions(): MediaMTXClientRegionOption[] {
|
||||
return [
|
||||
{
|
||||
value: 'hq',
|
||||
emoji: '🇺🇸',
|
||||
label: 'HQ Server A',
|
||||
whipEnabled: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type MediaMTXRegion = 'hq';
|
||||
export type MediaMTXRegion = 'hq' | 'ethande';
|
||||
|
||||
@@ -2,11 +2,13 @@ import { MediaMTXRegion } from './regions';
|
||||
|
||||
export interface MediaMTXEnvs {
|
||||
apiUrl: string;
|
||||
apiAuthHeader?: string;
|
||||
}
|
||||
|
||||
export const MEDIAMTX_SERVER_REGIONS: Record<MediaMTXRegion, MediaMTXEnvs> = {
|
||||
hq: {
|
||||
apiUrl: process.env.MEDIAMTX_API_HQ!,
|
||||
apiAuthHeader: getMediamtxApiAuthHeader(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,3 +21,13 @@ export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs {
|
||||
|
||||
return envs;
|
||||
}
|
||||
|
||||
function getMediamtxApiAuthHeader() {
|
||||
const apiKey = process.env.MEDIAMTX_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Basic ${Buffer.from(`hctv-api:${apiKey}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
531
apps/web/src/lib/utils/mediamtx/webrtc.ts
Normal file
531
apps/web/src/lib/utils/mediamtx/webrtc.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
// based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js
|
||||
// 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;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
token?: string;
|
||||
stream: MediaStream;
|
||||
videoCodec: string;
|
||||
videoBitrate: number;
|
||||
audioCodec: string;
|
||||
audioBitrate: number;
|
||||
audioVoice: boolean;
|
||||
onError?: OnError;
|
||||
onConnected?: OnConnected;
|
||||
};
|
||||
|
||||
type OfferData = {
|
||||
iceUfrag: string;
|
||||
icePwd: string;
|
||||
medias: string[];
|
||||
};
|
||||
|
||||
type ParsedIceServer = RTCIceServer & {
|
||||
credentialType?: 'password';
|
||||
};
|
||||
|
||||
/** WebRTC/WHIP publisher. */
|
||||
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'
|
||||
) {
|
||||
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.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;
|
||||
}
|
||||
|
||||
static #linkToIceServers(links: string | null): ParsedIceServer[] {
|
||||
if (links === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return links.split(', ').flatMap((link) => {
|
||||
const match = link.match(
|
||||
/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const iceServer: ParsedIceServer = {
|
||||
urls: [match[1]],
|
||||
};
|
||||
|
||||
if (match[3] !== undefined && match[4] !== undefined) {
|
||||
iceServer.username = this.#unquoteCredential(match[3]);
|
||||
iceServer.credential = this.#unquoteCredential(match[4]);
|
||||
iceServer.credentialType = 'password';
|
||||
}
|
||||
|
||||
return [iceServer];
|
||||
});
|
||||
}
|
||||
|
||||
static #parseOffer(offer: string): OfferData {
|
||||
const parsedOffer: OfferData = {
|
||||
iceUfrag: '',
|
||||
icePwd: '',
|
||||
medias: [],
|
||||
};
|
||||
|
||||
for (const line of offer.split('\r\n')) {
|
||||
if (line.startsWith('m=')) {
|
||||
parsedOffer.medias.push(line.slice('m='.length));
|
||||
} else if (parsedOffer.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
|
||||
parsedOffer.iceUfrag = line.slice('a=ice-ufrag:'.length);
|
||||
} else if (parsedOffer.icePwd === '' && line.startsWith('a=ice-pwd:')) {
|
||||
parsedOffer.icePwd = line.slice('a=ice-pwd:'.length);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedOffer;
|
||||
}
|
||||
|
||||
static #generateSdpFragment(offerData: OfferData, candidates: RTCIceCandidate[]): string {
|
||||
const candidatesByMedia: Record<number, RTCIceCandidate[]> = {};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const mid = candidate.sdpMLineIndex;
|
||||
if (mid === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidatesByMedia[mid] === undefined) {
|
||||
candidatesByMedia[mid] = [];
|
||||
}
|
||||
candidatesByMedia[mid].push(candidate);
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
for (const candidate of candidatesByMedia[mid]) {
|
||||
fragment += `a=${candidate.candidate}\r\n`;
|
||||
}
|
||||
}
|
||||
mid++;
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
static #setCodec(section: string, codec: string): string {
|
||||
const normalizedCodec = codec.toLowerCase();
|
||||
const lines = section.split('\r\n');
|
||||
const filteredLines: string[] = [];
|
||||
const payloadFormats: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('a=rtpmap:')) {
|
||||
filteredLines.push(line);
|
||||
} else if (line.toLowerCase().includes(normalizedCodec)) {
|
||||
payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const rewrittenLines: string[] = [];
|
||||
let firstLine = true;
|
||||
|
||||
for (const line of filteredLines) {
|
||||
if (firstLine) {
|
||||
firstLine = false;
|
||||
rewrittenLines.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' '));
|
||||
} else if (line.startsWith('a=fmtp:')) {
|
||||
if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
|
||||
rewrittenLines.push(line);
|
||||
}
|
||||
} else if (line.startsWith('a=rtcp-fb:')) {
|
||||
if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) {
|
||||
rewrittenLines.push(line);
|
||||
}
|
||||
} else {
|
||||
rewrittenLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return rewrittenLines.join('\r\n');
|
||||
}
|
||||
|
||||
static #setVideoBitrate(section: string, bitrate: number): string {
|
||||
let lines = section.split('\r\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('c=')) {
|
||||
lines = [
|
||||
...lines.slice(0, i + 1),
|
||||
`b=TIAS:${(bitrate * 1024).toString()}`,
|
||||
...lines.slice(i + 1),
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
static #setAudioBitrate(section: string, bitrate: number, voice: boolean): string {
|
||||
let opusPayloadFormat = '';
|
||||
const lines = section.split('\r\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('a=rtpmap:') && line.toLowerCase().includes('opus/')) {
|
||||
opusPayloadFormat = line.slice('a=rtpmap:'.length).split(' ')[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (opusPayloadFormat === '') {
|
||||
return section;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) {
|
||||
if (voice) {
|
||||
lines[i] =
|
||||
`a=fmtp:${opusPayloadFormat} minptime=10;useinbandfec=1;maxaveragebitrate=${(bitrate * 1024).toString()}`;
|
||||
} else {
|
||||
lines[i] =
|
||||
`a=fmtp:${opusPayloadFormat} maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=${(bitrate * 1024).toString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
static #editOffer(
|
||||
sdp: string,
|
||||
videoCodec: string,
|
||||
audioCodec: string,
|
||||
audioBitrate: number,
|
||||
audioVoice: boolean
|
||||
): string {
|
||||
const sections = sdp.split('m=');
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].startsWith('video')) {
|
||||
sections[i] = this.#setCodec(sections[i], videoCodec);
|
||||
} else if (sections[i].startsWith('audio')) {
|
||||
sections[i] = this.#setAudioBitrate(
|
||||
this.#setCodec(sections[i], audioCodec),
|
||||
audioBitrate,
|
||||
audioVoice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('m=');
|
||||
}
|
||||
|
||||
static #editAnswer(sdp: string, videoBitrate: number): string {
|
||||
const sections = sdp.split('m=');
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].startsWith('video')) {
|
||||
sections[i] = this.#setVideoBitrate(sections[i], videoBitrate);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('m=');
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
try {
|
||||
const iceServers = await this.requestIceServers();
|
||||
const offer = await this.setupPeerConnection(iceServers);
|
||||
const answer = await this.sendOffer(offer);
|
||||
await this.setAnswer(answer);
|
||||
} catch (error) {
|
||||
this.handleError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err: string): void {
|
||||
if (this.state === 'running') {
|
||||
this.resetConnection();
|
||||
this.disposeSession();
|
||||
this.state = 'restarting';
|
||||
|
||||
this.restartTimeout = setTimeout(() => {
|
||||
this.restartTimeout = null;
|
||||
this.state = 'running';
|
||||
void this.start();
|
||||
}, this.retryPause);
|
||||
|
||||
this.conf.onError?.(`${err}, retrying in some seconds`);
|
||||
}
|
||||
}
|
||||
|
||||
private authHeader(): HeadersInit {
|
||||
if (this.conf.user !== undefined && this.conf.user !== '') {
|
||||
const credentials = btoa(`${this.conf.user}:${this.conf.pass ?? ''}`);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
if (this.conf.token !== undefined && this.conf.token !== '') {
|
||||
return { Authorization: `Bearer ${this.conf.token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async requestIceServers(): Promise<ParsedIceServer[]> {
|
||||
const response = await fetch(this.conf.url, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
...this.authHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
return MediaMTXWebRTCPublisher.#linkToIceServers(response.headers.get('Link'));
|
||||
}
|
||||
|
||||
private async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
|
||||
if (this.state !== 'running') {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
this.pc = new RTCPeerConnection({
|
||||
iceServers,
|
||||
});
|
||||
|
||||
this.pc.onicecandidate = (event) => this.onLocalCandidate(event);
|
||||
this.pc.onconnectionstatechange = () => this.onConnectionState();
|
||||
this.trackSenders = {};
|
||||
|
||||
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();
|
||||
if (!offer.sdp) {
|
||||
throw new Error('missing offer SDP');
|
||||
}
|
||||
|
||||
this.offerData = MediaMTXWebRTCPublisher.#parseOffer(offer.sdp);
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
return offer.sdp;
|
||||
}
|
||||
|
||||
private async sendOffer(offer: string): Promise<string> {
|
||||
if (this.state !== 'running') {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
const editedOffer = MediaMTXWebRTCPublisher.#editOffer(
|
||||
offer,
|
||||
this.conf.videoCodec,
|
||||
this.conf.audioCodec,
|
||||
this.conf.audioBitrate,
|
||||
this.conf.audioVoice
|
||||
);
|
||||
|
||||
const response = await fetch(this.conf.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.authHeader(),
|
||||
'Content-Type': 'application/sdp',
|
||||
},
|
||||
body: editedOffer,
|
||||
});
|
||||
|
||||
switch (response.status) {
|
||||
case 201:
|
||||
break;
|
||||
case 400: {
|
||||
const errorBody = (await response.json()) as { error?: string };
|
||||
throw new Error(errorBody.error ?? 'bad request');
|
||||
}
|
||||
default:
|
||||
throw new Error(`bad status code ${response.status}`);
|
||||
}
|
||||
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('missing session location');
|
||||
}
|
||||
|
||||
this.sessionUrl = new URL(location, this.conf.url).toString();
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private async setAnswer(answer: string): Promise<void> {
|
||||
if (this.state !== 'running') {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
const peerConnection = this.pc;
|
||||
if (peerConnection === null) {
|
||||
throw new Error('missing peer connection');
|
||||
}
|
||||
|
||||
const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer(answer, this.conf.videoBitrate);
|
||||
|
||||
await peerConnection.setRemoteDescription(
|
||||
new RTCSessionDescription({
|
||||
type: 'answer',
|
||||
sdp: editedAnswer,
|
||||
})
|
||||
);
|
||||
|
||||
if (this.state !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.queuedCandidates.length !== 0) {
|
||||
this.sendLocalCandidates(this.queuedCandidates);
|
||||
this.queuedCandidates = [];
|
||||
}
|
||||
}
|
||||
|
||||
private onLocalCandidate(event: RTCPeerConnectionIceEvent): void {
|
||||
if (this.state !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.candidate !== null) {
|
||||
if (this.sessionUrl === null) {
|
||||
this.queuedCandidates.push(event.candidate);
|
||||
} else {
|
||||
this.sendLocalCandidates([event.candidate]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
|
||||
if (this.sessionUrl === null || this.offerData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetch(this.sessionUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/trickle-ice-sdpfrag',
|
||||
'If-Match': '*',
|
||||
},
|
||||
body: MediaMTXWebRTCPublisher.#generateSdpFragment(this.offerData, candidates),
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 204:
|
||||
break;
|
||||
case 404:
|
||||
throw new Error('stream not found');
|
||||
default:
|
||||
throw new Error(`bad status code ${response.status}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
private onConnectionState(): void {
|
||||
if (this.state !== 'running' || this.pc === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
|
||||
this.handleError('peer connection closed');
|
||||
} else if (this.pc.connectionState === 'connected') {
|
||||
this.conf.onConnected?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
ports:
|
||||
- 8890:8890/udp
|
||||
- 8891:8888
|
||||
- 8889:8889
|
||||
- 9997:9997
|
||||
- 9998:9998
|
||||
volumes:
|
||||
|
||||
@@ -11,6 +11,8 @@ hlsSegmentDuration: 2s
|
||||
hlsPartDuration: 500ms
|
||||
hlsSegmentCount: 10
|
||||
|
||||
webrtc: yes
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ hlsSegmentDuration: 2s
|
||||
hlsPartDuration: 1s
|
||||
hlsSegmentCount: 10
|
||||
|
||||
webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcAdditionalHosts: []
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://hctv:3000/api/mediamtx/publish
|
||||
authHTTPAddress: https://hackclub.tv/api/mediamtx/publish
|
||||
|
||||
api: yes
|
||||
apiAddress: 0.0.0.0:9997
|
||||
metrics: yes
|
||||
metricsAddress: :9998
|
||||
|
||||
12
docker/mediamtx/mirror/.env.example
Normal file
12
docker/mediamtx/mirror/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
ACME_EMAIL=ops@hackclub.tv
|
||||
|
||||
# public hostnames and stuff
|
||||
MEDIAMTX_HLS_HOST=hls.hackclub.tv
|
||||
MEDIAMTX_WEBRTC_HOST=whip.hackclub.tv
|
||||
MEDIAMTX_API_HOST=mmtxapi.hackclub.tv
|
||||
|
||||
# public ip for webrtc stuff
|
||||
MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS=203.0.113.10
|
||||
|
||||
# mediamtx publish route on hctv
|
||||
MEDIAMTX_AUTH_HTTP_ADDRESS=https://hackclub.tv/api/mediamtx/publish
|
||||
63
docker/mediamtx/mirror/docker-compose.yml
Normal file
63
docker/mediamtx/mirror/docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.5
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.srt.address=:8890/udp
|
||||
- --entrypoints.webrtc-ice.address=:8189/udp
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
- 8890:8890/udp
|
||||
- 8189:8189/udp
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt:/letsencrypt
|
||||
restart: unless-stopped
|
||||
|
||||
mediamtx:
|
||||
image: bluenviron/mediamtx:1
|
||||
volumes:
|
||||
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||
environment:
|
||||
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS}
|
||||
MTX_AUTHHTTPADDRESS: ${MEDIAMTX_AUTH_HTTP_ADDRESS}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
|
||||
- traefik.http.routers.mediamtx-hls.rule=Host(`${MEDIAMTX_HLS_HOST}`)
|
||||
- traefik.http.routers.mediamtx-hls.entrypoints=websecure
|
||||
- traefik.http.routers.mediamtx-hls.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.mediamtx-hls.service=mediamtx-hls
|
||||
- traefik.http.services.mediamtx-hls.loadbalancer.server.port=8888
|
||||
|
||||
- traefik.http.routers.mediamtx-webrtc.rule=Host(`${MEDIAMTX_WEBRTC_HOST}`)
|
||||
- traefik.http.routers.mediamtx-webrtc.entrypoints=websecure
|
||||
- traefik.http.routers.mediamtx-webrtc.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.mediamtx-webrtc.service=mediamtx-webrtc
|
||||
- traefik.http.services.mediamtx-webrtc.loadbalancer.server.port=8889
|
||||
|
||||
- traefik.http.routers.mediamtx-api.rule=Host(`${MEDIAMTX_API_HOST}`)
|
||||
- traefik.http.routers.mediamtx-api.entrypoints=websecure
|
||||
- traefik.http.routers.mediamtx-api.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.mediamtx-api.service=mediamtx-api
|
||||
- traefik.http.services.mediamtx-api.loadbalancer.server.port=9997
|
||||
|
||||
- traefik.udp.routers.mediamtx-srt.entrypoints=srt
|
||||
- traefik.udp.routers.mediamtx-srt.service=mediamtx-srt
|
||||
- traefik.udp.services.mediamtx-srt.loadbalancer.server.port=8890
|
||||
|
||||
- traefik.udp.routers.mediamtx-webrtc-ice.entrypoints=webrtc-ice
|
||||
- traefik.udp.routers.mediamtx-webrtc-ice.service=mediamtx-webrtc-ice
|
||||
- traefik.udp.services.mediamtx-webrtc-ice.loadbalancer.server.port=8189
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
letsencrypt:
|
||||
26
docker/mediamtx/mirror/mediamtx.yml
Normal file
26
docker/mediamtx/mirror/mediamtx.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
paths:
|
||||
all:
|
||||
source: publisher
|
||||
|
||||
srt: yes
|
||||
srtAddress: :8890
|
||||
|
||||
hls: yes
|
||||
hlsVariant: lowLatency
|
||||
hlsSegmentDuration: 2s
|
||||
hlsPartDuration: 1s
|
||||
hlsSegmentCount: 10
|
||||
|
||||
webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcAdditionalHosts: []
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: https://hackclub.tv/api/mediamtx/publish
|
||||
|
||||
api: yes
|
||||
apiAddress: :9997
|
||||
|
||||
metrics: yes
|
||||
metricsAddress: :9998
|
||||
Reference in New Issue
Block a user