diff --git a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts index 52497d9..6c23468 100644 --- a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts +++ b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts @@ -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 { 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; + }; } - }); -} \ No newline at end of file + | { + response: Response; + }; diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx index 484b1d5..16f9f57 100644 --- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx @@ -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(null); const [region, setRegion] = useState('hq'); const channelList = useOwnedChannels(); + const { + streamKey, + isRegenerating: isRegeneratingStreamKey, + regenerateStreamKey, + } = useChannelStreamKey(channel.name, channel.streamKey?.key); const router = useRouter(); const channelSettingsFormRef = useRef(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({
c.channel)} + includeCreate value={channel.name} onSelect={(value) => { if (value === 'create') { @@ -561,7 +556,12 @@ export default function ChannelSettingsClient({ )}
- - + - {error ?

{error}

: null} + {error ?

{error}

: null} ); } - -async function getPreferredVideoCodec(): Promise { - 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.' - ); -} diff --git a/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx index ec1ddb8..48720b8 100644 --- a/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx +++ b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx @@ -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 ( - + + {channelList.map((channel) => ( @@ -25,15 +32,22 @@ export function ChannelSelect(props: Props) {
- {channel.name[0]} + {channel.name[0]?.toUpperCase()}
{channel.name}
))} - } className='h-11'> - Create Channel - + {includeCreate ? ( + } + className="h-11" + > + Create Channel + + ) : null}
); @@ -42,5 +56,9 @@ export function ChannelSelect(props: Props) { interface Props { channelList: Channel[]; value?: string; + disabled?: boolean; + includeCreate?: boolean; onSelect: (value: string) => void; -} \ No newline at end of file + placeholder?: string; + triggerClassName?: string; +} diff --git a/apps/web/src/lib/hooks/useChannelStreamKey.ts b/apps/web/src/lib/hooks/useChannelStreamKey.ts new file mode 100644 index 0000000..16c55cd --- /dev/null +++ b/apps/web/src/lib/hooks/useChannelStreamKey.ts @@ -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 { + 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 { + const response = await fetch(`${url}?channel=${encodeURIComponent(channelName)}`); + return parseStreamKeyResponse(response); +} + +async function regenerateStreamKey( + url: string, + { arg: channelName }: { arg: string } +): Promise { + 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( + 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, + }; +} diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts new file mode 100644 index 0000000..5000430 --- /dev/null +++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts @@ -0,0 +1,254 @@ +// completely generated by gpt-5.4 + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc'; + +const HLS_COMPATIBLE_VIDEO_CODECS = [ + ['h264', 'h264/90000'], + ['vp9', 'vp9/90000'], + ['av1', 'av1/90000'], + ['h265', 'h265/90000'], +] as const; + +const DISPLAY_MEDIA_OPTIONS: ScreenCaptureOptions = { + video: true, + audio: true, + monitorTypeSurfaces: 'include', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + systemAudio: 'include', +}; + +export function useScreensharePublisher({ + channelName, + streamKey, +}: UseScreensharePublisherOptions) { + const previewRef = useRef(null); + const captureStreamRef = useRef(null); + const captureCleanupRef = useRef<(() => void) | null>(null); + const publisherRef = useRef(null); + const [publishState, setPublishState] = useState('idle'); + const [error, setError] = useState(null); + + const setPreviewStream = useCallback((stream: MediaStream | null) => { + if (previewRef.current) { + previewRef.current.srcObject = stream; + } + }, []); + + const detachCaptureCleanup = useCallback(() => { + captureCleanupRef.current?.(); + captureCleanupRef.current = null; + }, []); + + const clearCaptureStream = useCallback(() => { + detachCaptureCleanup(); + stopTracks(captureStreamRef.current); + captureStreamRef.current = null; + setPreviewStream(null); + }, [detachCaptureCleanup, setPreviewStream]); + + const closePublisher = useCallback(() => { + const publisher = publisherRef.current; + + publisherRef.current = null; + publisher?.close(); + }, []); + + const disposeCurrentSession = useCallback(() => { + closePublisher(); + clearCaptureStream(); + }, [clearCaptureStream, closePublisher]); + + const stopPublishing = useCallback(() => { + disposeCurrentSession(); + setError(null); + setPublishState('idle'); + }, [disposeCurrentSession]); + + const attachCaptureStopListener = useCallback( + (stream: MediaStream) => { + const [videoTrack] = stream.getVideoTracks(); + + if (!videoTrack) { + captureCleanupRef.current = null; + return; + } + + const handleEnded = () => { + stopPublishing(); + }; + + videoTrack.addEventListener('ended', handleEnded); + captureCleanupRef.current = () => { + videoTrack.removeEventListener('ended', handleEnded); + }; + }, + [stopPublishing] + ); + + const commitCaptureStream = useCallback( + (nextStream: MediaStream) => { + const previousStream = captureStreamRef.current; + + detachCaptureCleanup(); + captureStreamRef.current = nextStream; + setPreviewStream(nextStream); + attachCaptureStopListener(nextStream); + stopTracks(previousStream); + }, + [attachCaptureStopListener, detachCaptureCleanup, setPreviewStream] + ); + + const startPublishing = useCallback(async () => { + if (!channelName) { + setError('Select a channel before starting your stream.'); + return; + } + + if (!streamKey) { + setError('Stream key unavailable for the selected channel.'); + return; + } + + try { + setError(null); + setPublishState('connecting'); + + const videoCodec = await getPreferredVideoCodec(); + const stream = await requestCaptureStream(); + + commitCaptureStream(stream); + + const publisher = new MediaMTXWebRTCPublisher({ + url: getWhipUrl(channelName), + stream, + videoCodec, + videoBitrate: 2000, + audioCodec: 'opus', + audioBitrate: 64, + audioVoice: true, + user: 'user', + pass: streamKey, + onConnected: () => { + if (publisherRef.current !== publisher) { + return; + } + + setPublishState('live'); + }, + onError: (message) => { + if (publisherRef.current !== publisher) { + return; + } + + setError(message); + setPublishState('connecting'); + }, + }); + + publisherRef.current = publisher; + } catch (err) { + disposeCurrentSession(); + setPublishState('idle'); + setError(getErrorMessage(err, 'Failed to start publishing')); + } + }, [channelName, commitCaptureStream, disposeCurrentSession, streamKey]); + + const changeSource = useCallback(async () => { + const publisher = publisherRef.current; + + if (!publisher) { + return; + } + + let nextStream: MediaStream | null = null; + + try { + setError(null); + setPublishState('switching'); + + nextStream = await requestCaptureStream(); + await publisher.replaceStream(nextStream); + commitCaptureStream(nextStream); + setPublishState('live'); + } catch (err) { + stopTracks(nextStream); + setPublishState(publisherRef.current ? 'live' : 'idle'); + setError(getErrorMessage(err, 'Failed to change screenshare source')); + } + }, [commitCaptureStream]); + + useEffect(() => { + return () => { + disposeCurrentSession(); + }; + }, [disposeCurrentSession]); + + return { + changeSource, + error, + isLive: publishState === 'live', + isSessionActive: publishState !== 'idle', + isStarting: publishState === 'connecting', + isSwitchingSource: publishState === 'switching', + previewRef, + startPublishing, + stopPublishing, + }; +} + +async function requestCaptureStream() { + return navigator.mediaDevices.getDisplayMedia(DISPLAY_MEDIA_OPTIONS as DisplayMediaStreamOptions); +} + +function getWhipUrl(channelName: string) { + return `http://localhost:8889/${encodeURIComponent(channelName)}/whip`; +} + +function stopTracks(stream: MediaStream | null) { + stream?.getTracks().forEach((track) => track.stop()); +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +async function getPreferredVideoCodec(): Promise { + const tempPc = new RTCPeerConnection(); + + try { + tempPc.addTransceiver('video', { direction: 'sendonly' }); + + const offer = await tempPc.createOffer(); + const sdp = offer.sdp?.toLowerCase() ?? ''; + + for (const [codec, needle] of HLS_COMPATIBLE_VIDEO_CODECS) { + if (sdp.includes(needle)) { + return codec; + } + } + } finally { + tempPc.close(); + } + + throw new Error( + 'This browser does not expose an HLS-compatible WebRTC video codec. MediaMTX HLS supports AV1, VP9, H265, and H264, but not VP8.' + ); +} + +type PublishState = 'idle' | 'connecting' | 'live' | 'switching'; + +type UseScreensharePublisherOptions = { + channelName: string; + streamKey?: string | null; +}; + +type ScreenCaptureOptions = DisplayMediaStreamOptions & { + monitorTypeSurfaces?: 'include' | 'exclude'; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; + systemAudio?: 'include' | 'exclude'; +}; diff --git a/apps/web/src/lib/utils/mediamtx/webrtc.ts b/apps/web/src/lib/utils/mediamtx/webrtc.ts index 68470f7..295dba0 100644 --- a/apps/web/src/lib/utils/mediamtx/webrtc.ts +++ b/apps/web/src/lib/utils/mediamtx/webrtc.ts @@ -1,9 +1,10 @@ // based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js -// modified by codex to typescript +// modified by codex to typescript and to suit the platform's needs! export type OnError = (err: string) => void; export type OnConnected = () => void; export type PublisherState = 'running' | 'restarting' | 'closed'; +type MediaKind = 'audio' | 'video'; export type PublisherConfig = { url: string; @@ -34,40 +35,85 @@ type ParsedIceServer = RTCIceServer & { export class MediaMTXWebRTCPublisher { private readonly retryPause = 2000; private readonly conf: PublisherConfig; + private stream: MediaStream; private state: PublisherState = 'running'; private restartTimeout: ReturnType | null = null; private pc: RTCPeerConnection | null = null; private offerData: OfferData | null = null; private sessionUrl: string | null = null; private queuedCandidates: RTCIceCandidate[] = []; + private trackSenders: Partial> = {}; constructor(conf: PublisherConfig) { if ( - typeof window === 'undefined' - || typeof RTCPeerConnection === 'undefined' - || typeof MediaStream === 'undefined' + typeof window === 'undefined' || + typeof RTCPeerConnection === 'undefined' || + typeof MediaStream === 'undefined' ) { - throw new Error( - 'MediaMTXWebRTCPublisher can only be used in a browser environment.' - ); + throw new Error('MediaMTXWebRTCPublisher can only be used in a browser environment.'); } this.conf = conf; + this.stream = conf.stream; this.start(); } close = (): void => { this.state = 'closed'; - if (this.pc !== null) { - this.pc.close(); - } - if (this.restartTimeout !== null) { clearTimeout(this.restartTimeout); } + + this.resetConnection(); + this.disposeSession(); }; + replaceStream = async (stream: MediaStream): Promise => { + if (this.state !== 'running' || this.pc === null) { + throw new Error('publisher is not running'); + } + + const nextTracks: Record = { + audio: stream.getAudioTracks()[0] ?? null, + video: stream.getVideoTracks()[0] ?? null, + }; + + await Promise.all( + (['audio', 'video'] as const).map(async (kind) => { + const sender = this.trackSenders[kind]; + + if (!sender) { + return; + } + + await sender.replaceTrack(nextTracks[kind]); + }) + ); + + this.stream = stream; + }; + + private resetConnection(): void { + if (this.pc !== null) { + this.pc.close(); + this.pc = null; + } + + this.offerData = null; + this.queuedCandidates = []; + this.trackSenders = {}; + } + + private disposeSession(): void { + if (this.sessionUrl !== null) { + void fetch(this.sessionUrl, { + method: 'DELETE', + }); + this.sessionUrl = null; + } + } + static #unquoteCredential(value: string): string { return JSON.parse(`"${value}"`) as string; } @@ -120,10 +166,7 @@ export class MediaMTXWebRTCPublisher { return parsedOffer; } - static #generateSdpFragment( - offerData: OfferData, - candidates: RTCIceCandidate[] - ): string { + static #generateSdpFragment(offerData: OfferData, candidates: RTCIceCandidate[]): string { const candidatesByMedia: Record = {}; for (const candidate of candidates) { @@ -138,15 +181,13 @@ export class MediaMTXWebRTCPublisher { candidatesByMedia[mid].push(candidate); } - let fragment = `a=ice-ufrag:${offerData.iceUfrag}\r\n` - + `a=ice-pwd:${offerData.icePwd}\r\n`; + let fragment = `a=ice-ufrag:${offerData.iceUfrag}\r\n` + `a=ice-pwd:${offerData.icePwd}\r\n`; let mid = 0; for (const media of offerData.medias) { if (candidatesByMedia[mid] !== undefined) { - fragment += `m=${media}\r\n` - + `a=mid:${mid}\r\n`; + fragment += `m=${media}\r\n` + `a=mid:${mid}\r\n`; for (const candidate of candidatesByMedia[mid]) { fragment += `a=${candidate.candidate}\r\n`; @@ -292,21 +333,8 @@ export class MediaMTXWebRTCPublisher { private handleError(err: string): void { if (this.state === 'running') { - if (this.pc !== null) { - this.pc.close(); - this.pc = null; - } - - this.offerData = null; - - if (this.sessionUrl !== null) { - void fetch(this.sessionUrl, { - method: 'DELETE', - }); - this.sessionUrl = null; - } - - this.queuedCandidates = []; + this.resetConnection(); + this.disposeSession(); this.state = 'restarting'; this.restartTimeout = setTimeout(() => { @@ -352,9 +380,14 @@ export class MediaMTXWebRTCPublisher { this.pc.onicecandidate = (event) => this.onLocalCandidate(event); this.pc.onconnectionstatechange = () => this.onConnectionState(); + this.trackSenders = {}; - this.conf.stream.getTracks().forEach((track) => { - this.pc?.addTrack(track, this.conf.stream); + this.stream.getTracks().forEach((track) => { + const sender = this.pc?.addTrack(track, this.stream); + + if (sender && (track.kind === 'audio' || track.kind === 'video')) { + this.trackSenders[track.kind] = sender; + } }); const offer = await this.pc.createOffer(); @@ -421,10 +454,7 @@ export class MediaMTXWebRTCPublisher { throw new Error('missing peer connection'); } - const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer( - answer, - this.conf.videoBitrate - ); + const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer(answer, this.conf.videoBitrate); await peerConnection.setRemoteDescription( new RTCSessionDescription({ @@ -490,10 +520,7 @@ export class MediaMTXWebRTCPublisher { return; } - if ( - this.pc.connectionState === 'failed' - || this.pc.connectionState === 'closed' - ) { + if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') { this.handleError('peer connection closed'); } else if (this.pc.connectionState === 'connected') { this.conf.onConnected?.();