Merge pull request #66 from SrIzan10/feat/browser-streaming

feat: #68 feat/browser streaming
This commit is contained in:
2026-04-29 16:16:40 +02:00
committed by GitHub
21 changed files with 1783 additions and 66 deletions

View File

@@ -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=

View File

@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
action = parsedAction;
protocol = parsedProtocol;
if (parsedAction === 'publish' && parsedProtocol === 'srt') {
if (parsedAction === 'publish' && (parsedProtocol === 'srt' || parsedProtocol === 'webrtc')) {
const channelKey = await redis.get(`streamKey:${path}`);
if (channelKey) {
@@ -69,7 +69,8 @@ export async function POST(request: NextRequest) {
return finish('youre in yay', 200, 'authorized_publish');
}
} else if (parsedAction === 'read' && parsedProtocol === 'hls') {
}
if (parsedAction === 'read' && parsedProtocol === 'hls') {
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
return finish('authorized (hls read key for thumbs)', 200, 'authorized_thumbnail');
}
@@ -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');
}

View File

@@ -1,46 +1,106 @@
import { NextRequest } from 'next/server';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from "next/server";
import { regenerateStreamKey } from '@/lib/db/streamKey';
import { prisma } from '@hctv/db';
export async function POST(request: NextRequest) {
const channelName = await readChannelNameFromBody(request);
if (!channelName) {
return badRequestResponse();
}
const result = await getAuthorizedChannel(channelName);
if ('response' in result) {
return result.response;
}
const streamKey = await regenerateStreamKey(result.channel.id, channelName);
return Response.json({ key: streamKey.key });
}
export async function GET(request: NextRequest) {
const channelName = request.nextUrl.searchParams.get('channel');
if (!isValidChannelName(channelName)) {
return badRequestResponse();
}
const result = await getAuthorizedChannel(channelName);
if ('response' in result) {
return result.response;
}
const streamKey = await prisma.streamKey.findUnique({
where: { channelId: result.channel.id },
select: { key: true },
});
if (!streamKey) {
return new Response('Stream key not found', { status: 404 });
}
return Response.json({ key: streamKey.key });
}
async function getAuthorizedChannel(channelName: string): Promise<AuthorizedChannelResult> {
const { user } = await validateRequest();
const body = await request.json();
const { channel } = body;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return { response: unauthorizedResponse() };
}
if (!channel || typeof channel !== 'string') {
return new Response('Bad Request', { status: 400 });
}
const channelInfo = await prisma.channel.findUnique({
where: { name: channel },
include: {
owner: true,
managers: true
}
const channel = await prisma.channel.findUnique({
where: { name: channelName },
select: {
id: true,
ownerId: true,
managers: {
where: { id: user.id },
select: { id: true },
},
},
});
if (!channelInfo) {
return new Response('Channel not found', { status: 404 });
if (!channel) {
return { response: new Response('Channel not found', { status: 404 }) };
}
const isBroadcaster =
channelInfo.ownerId === user.id ||
channelInfo.managers.some(m => m.id === user.id);
const isBroadcaster = channel.ownerId === user.id || channel.managers.length > 0;
if (!isBroadcaster) {
return new Response('Unauthorized', { status: 401 });
return { response: unauthorizedResponse() };
}
const streamKey = await regenerateStreamKey(channelInfo.id, channel);
return { channel: { id: channel.id } };
}
return new Response(JSON.stringify({ key: streamKey.key }), {
status: 200,
headers: {
'Content-Type': 'application/json'
async function readChannelNameFromBody(request: NextRequest) {
try {
const body = await request.json();
return isValidChannelName(body?.channel) ? body.channel : null;
} catch {
return null;
}
}
function isValidChannelName(channelName: unknown): channelName is string {
return typeof channelName === 'string' && channelName.length > 0;
}
function badRequestResponse() {
return new Response('Bad Request', { status: 400 });
}
function unauthorizedResponse() {
return new Response('Unauthorized', { status: 401 });
}
type AuthorizedChannelResult =
| {
channel: {
id: string;
};
}
});
}
| {
response: Response;
};

View File

@@ -67,6 +67,7 @@ import { parseAsString, useQueryState } from 'nuqs';
import { Write } from '@/components/ui/channel-desc-fancy-area/write';
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
import { UploadButton } from '@/lib/uploadthing';
import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey';
import { useOwnedChannels } from '@/lib/hooks/useUserList';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
import { useRouter } from 'next/navigation';
@@ -112,7 +113,6 @@ export default function ChannelSettingsClient({
isPersonal,
}: ChannelSettingsClientProps) {
const confirm = useConfirm();
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState({
streamKey: false,
@@ -123,6 +123,11 @@ export default function ChannelSettingsClient({
const [uploadError, setUploadError] = useState<string | null>(null);
const [region, setRegion] = useState<MediaMTXRegion>('hq');
const channelList = useOwnedChannels();
const {
streamKey,
isRegenerating: isRegeneratingStreamKey,
regenerateStreamKey,
} = useChannelStreamKey(channel.name, channel.streamKey?.key);
const router = useRouter();
const channelSettingsFormRef = useRef<HTMLFormElement>(null);
@@ -185,22 +190,11 @@ export default function ChannelSettingsClient({
}
};
const regenerateStreamKey = async () => {
const handleRegenerateStreamKey = async () => {
try {
const response = await fetch('/api/rtmp/streamKey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: channel.name }),
});
if (response.ok) {
const data = await response.json();
setStreamKey(data.key);
toast.success('Stream key regenerated successfully');
} else {
toast.error('Failed to regenerate stream key');
}
} catch (error) {
await regenerateStreamKey();
toast.success('Stream key regenerated successfully');
} catch {
toast.error('Failed to regenerate stream key');
}
};
@@ -247,6 +241,7 @@ export default function ChannelSettingsClient({
<div>
<ChannelSelect
channelList={channelList.channels.map((c) => c.channel)}
includeCreate
value={channel.name}
onSelect={(value) => {
if (value === 'create') {
@@ -561,7 +556,12 @@ export default function ChannelSettingsClient({
)}
</button>
</div>
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
<Button
onClick={handleRegenerateStreamKey}
variant="outline"
size="smicon"
loading={isRegeneratingStreamKey}
>
<Key className="h-4 w-4" />
</Button>
<Button

View File

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

View File

@@ -1,8 +1,7 @@
'use client'
'use client';
import type { Channel } from "@hctv/db";
import * as React from 'react';
import { Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Select,
SelectContent,
@@ -11,13 +10,21 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import type { Channel } from '@hctv/db';
export function ChannelSelect(props: Props) {
const { channelList } = props;
const {
channelList,
disabled = false,
includeCreate = false,
placeholder = 'Channel',
triggerClassName,
} = props;
return (
<Select onValueChange={props.onSelect} value={props.value}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Channel" />
<Select disabled={disabled} onValueChange={props.onSelect} value={props.value}>
<SelectTrigger className={cn('w-[180px]', triggerClassName)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{channelList.map((channel) => (
@@ -25,15 +32,22 @@ export function ChannelSelect(props: Props) {
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
<AvatarFallback>{channel.name[0]}</AvatarFallback>
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="font-medium">{channel.name}</div>
</div>
</SelectItem>
))}
<SelectItem key="create" value="create" icon={<Plus className="h-4 w-4" />} className='h-11'>
Create Channel
</SelectItem>
{includeCreate ? (
<SelectItem
key="create"
value="create"
icon={<Plus className="h-4 w-4" />}
className="h-11"
>
Create Channel
</SelectItem>
) : null}
</SelectContent>
</Select>
);
@@ -42,5 +56,9 @@ export function ChannelSelect(props: Props) {
interface Props {
channelList: Channel[];
value?: string;
disabled?: boolean;
includeCreate?: boolean;
onSelect: (value: string) => void;
}
placeholder?: string;
triggerClassName?: string;
}

View File

@@ -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 ? (

View File

@@ -0,0 +1,81 @@
'use client';
import { useCallback } from 'react';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
interface StreamKeyResponse {
key: string;
}
async function parseStreamKeyResponse(response: Response): Promise<StreamKeyResponse> {
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Failed to load stream key');
}
return response.json();
}
async function fetchStreamKey(
[url, channelName]: readonly [string, string]
): Promise<StreamKeyResponse> {
const response = await fetch(`${url}?channel=${encodeURIComponent(channelName)}`);
return parseStreamKeyResponse(response);
}
async function regenerateStreamKey(
url: string,
{ arg: channelName }: { arg: string }
): Promise<StreamKeyResponse> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel: channelName }),
});
return parseStreamKeyResponse(response);
}
export function useChannelStreamKey(channelName?: string, initialKey?: string | null) {
const swrKey = channelName ? (['/api/rtmp/streamKey', channelName] as const) : null;
const { data, error, isLoading, isValidating, mutate } = useSWR<StreamKeyResponse>(
swrKey,
fetchStreamKey,
{
fallbackData: initialKey ? { key: initialKey } : undefined,
revalidateOnFocus: false,
}
);
const { trigger, isMutating } = useSWRMutation('/api/rtmp/streamKey', regenerateStreamKey);
const refreshStreamKey = useCallback(async () => {
if (!channelName) {
return undefined;
}
return mutate();
}, [channelName, mutate]);
const handleRegenerateStreamKey = useCallback(async () => {
if (!channelName) {
throw new Error('Select a channel before regenerating its stream key');
}
const nextStreamKey = await trigger(channelName);
await mutate(nextStreamKey, { revalidate: false });
return nextStreamKey.key;
}, [channelName, mutate, trigger]);
return {
streamKey: data?.key ?? initialKey ?? '',
error,
isLoading,
isRefreshing: isValidating && !isLoading,
isRegenerating: isMutating,
refreshStreamKey,
regenerateStreamKey: handleRegenerateStreamKey,
};
}

View File

@@ -0,0 +1,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';
};

View File

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

View File

@@ -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,
},
];
}

View File

@@ -1 +1 @@
export type MediaMTXRegion = 'hq';
export type MediaMTXRegion = 'hq' | 'ethande';

View File

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

View 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;

View File

@@ -45,7 +45,8 @@ export async function registerThumbnailWorker(): Promise<void> {
);
return { success: true };
} catch (ffmpegError) {
console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
// commenting since its mostly due to the fact that the stream is likely offline
// console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
}
} catch (e) {

View File

@@ -28,6 +28,7 @@ services:
ports:
- 8890:8890/udp
- 8891:8888
- 8889:8889
- 9997:9997
- 9998:9998
volumes:

View File

@@ -11,6 +11,8 @@ hlsSegmentDuration: 2s
hlsPartDuration: 500ms
hlsSegmentCount: 10
webrtc: yes
authMethod: http
authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish

View File

@@ -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

View 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

View 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:

View 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