feat(bs): error handling and stuff

This commit is contained in:
2026-04-22 21:56:54 +02:00
parent da968232ad
commit 90d73275b2
2 changed files with 329 additions and 15 deletions

View File

@@ -1,8 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
AlertTriangle,
CheckCircle2,
CircleAlert,
LoaderCircle,
Radio,
RefreshCw,
} from 'lucide-react';
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
@@ -30,12 +42,14 @@ export default function Page() {
isLoading: isLoadingStreamKey,
} = useChannelStreamKey(selectedChannel || undefined);
const {
browserWarning,
changeSource,
error,
issue,
isLive,
isSessionActive,
isStarting,
isSwitchingSource,
publishState,
previewRef,
startPublishing,
stopPublishing,
@@ -50,6 +64,8 @@ export default function Page() {
const canStartPublishing =
!isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
const statusMeta = getStatusMeta(publishState);
const primaryIssue = issue ?? browserWarning;
useEffect(() => {
if (isSessionActive) {
@@ -108,7 +124,70 @@ export default function Page() {
</p>
) : null}
{streamKeyError ? <p className="text-sm text-destructive">{streamKeyError.message}</p> : null}
<Card className={statusMeta.cardClassName}>
<CardContent className="flex items-start gap-3 p-4">
<div className="flex items-start gap-3">
<statusMeta.icon className={`mt-0.5 h-5 w-5 shrink-0 ${statusMeta.iconClassName}`} />
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{statusMeta.title}</p>
<Badge variant={statusMeta.badgeVariant}>{statusMeta.badgeLabel}</Badge>
</div>
<p className="text-sm text-muted-foreground">{statusMeta.description}</p>
</div>
</div>
</CardContent>
</Card>
{streamKeyError ? (
<ActionPanel
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 ? (
<ActionPanel
actions={
<>
{!isSessionActive && primaryIssue.context !== 'warning' ? (
<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}
</>
}
description={primaryIssue.description}
icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
title={primaryIssue.title}
tone={primaryIssue.tone}
/>
) : null}
<video
ref={previewRef}
@@ -134,8 +213,101 @@ export default function Page() {
Stop
</Button>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}
function ActionPanel({ actions, description, icon: Icon, title, tone }: ActionPanelProps) {
const isWarning = tone === 'warning';
return (
<div
className={
isWarning
? 'rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-foreground'
: 'rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-foreground'
}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
<Icon
className={`mt-0.5 h-4 w-4 shrink-0 ${isWarning ? 'text-amber-500' : 'text-destructive'}`}
/>
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
{actions ? <div className="flex gap-2 md:shrink-0">{actions}</div> : null}
</div>
</div>
);
}
function getStatusMeta(publishState: PublishState) {
switch (publishState) {
case 'connecting':
return {
badgeLabel: 'Starting',
badgeVariant: 'secondary' as const,
cardClassName: 'border-blue-500/30 bg-blue-500/5',
description: 'Approve the browser picker and keep this page open while we connect.',
icon: LoaderCircle,
iconClassName: 'animate-spin text-blue-500',
title: 'Preparing your stream',
};
case 'live':
return {
badgeLabel: 'Live',
badgeVariant: 'default' as const,
cardClassName: 'border-emerald-500/30 bg-emerald-500/5',
description: 'Your stream is live. You can switch sources without ending the broadcast.',
icon: Radio,
iconClassName: 'text-emerald-500',
title: 'Broadcast is live',
};
case 'switching':
return {
badgeLabel: 'Switching',
badgeVariant: 'secondary' as const,
cardClassName: 'border-amber-500/30 bg-amber-500/5',
description: 'Choose a new window, tab, or display in the browser picker.',
icon: LoaderCircle,
iconClassName: 'animate-spin text-amber-500',
title: 'Switching shared source',
};
default:
return {
badgeLabel: 'Ready',
badgeVariant: 'outline' as const,
cardClassName: '',
description: 'Choose a channel and server, then start sharing your screen.',
icon: CheckCircle2,
iconClassName: 'text-primary',
title: 'Ready to stream',
};
}
}
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 ActionPanelProps = {
actions?: ReactNode;
description: string;
icon: LucideIcon;
title: string;
tone: 'warning' | 'destructive';
};
type PublishState = 'idle' | 'connecting' | 'live' | 'switching';

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
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';
@@ -31,7 +31,8 @@ export function useScreensharePublisher({
const captureCleanupRef = useRef<(() => void) | null>(null);
const publisherRef = useRef<MediaMTXWebRTCPublisher | null>(null);
const [publishState, setPublishState] = useState<PublishState>('idle');
const [error, setError] = useState<string | null>(null);
const [issue, setIssue] = useState<PublisherIssue | null>(null);
const browserWarning = useMemo(() => getBrowserWarning(), []);
const setPreviewStream = useCallback((stream: MediaStream | null) => {
if (previewRef.current) {
@@ -65,7 +66,7 @@ export function useScreensharePublisher({
const stopPublishing = useCallback(() => {
disposeCurrentSession();
setError(null);
setIssue(null);
setPublishState('idle');
}, [disposeCurrentSession]);
@@ -105,17 +106,27 @@ export function useScreensharePublisher({
const startPublishing = useCallback(async () => {
if (!channelName) {
setError('Select a channel before starting your stream.');
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) {
setError('Stream key unavailable for the selected channel.');
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 {
setError(null);
setIssue(null);
setPublishState('connecting');
const videoCodec = await getPreferredVideoCodec();
@@ -145,7 +156,7 @@ export function useScreensharePublisher({
return;
}
setError(message);
setIssue(classifyPublisherIssue(message, 'publish'));
setPublishState('connecting');
},
});
@@ -154,7 +165,7 @@ export function useScreensharePublisher({
} catch (err) {
disposeCurrentSession();
setPublishState('idle');
setError(getErrorMessage(err, 'Failed to start publishing'));
setIssue(classifyPublisherIssue(err, 'start'));
}
}, [channelName, commitCaptureStream, disposeCurrentSession, region, streamKey]);
@@ -168,7 +179,7 @@ export function useScreensharePublisher({
let nextStream: MediaStream | null = null;
try {
setError(null);
setIssue(null);
setPublishState('switching');
nextStream = await requestCaptureStream();
@@ -178,7 +189,7 @@ export function useScreensharePublisher({
} catch (err) {
stopTracks(nextStream);
setPublishState(publisherRef.current ? 'live' : 'idle');
setError(getErrorMessage(err, 'Failed to change screenshare source'));
setIssue(classifyPublisherIssue(err, 'switch'));
}
}, [commitCaptureStream]);
@@ -189,12 +200,14 @@ export function useScreensharePublisher({
}, [disposeCurrentSession]);
return {
browserWarning,
changeSource,
error,
issue,
isLive: publishState === 'live',
isSessionActive: publishState !== 'idle',
isStarting: publishState === 'connecting',
isSwitchingSource: publishState === 'switching',
publishState,
previewRef,
startPublishing,
stopPublishing,
@@ -219,6 +232,126 @@ 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' : '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.'
: 'Approve the browser screen-share prompt, then try again.',
title:
context === 'switch'
? 'Source switch was cancelled or blocked'
: '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.'
: 'Try again. If it keeps failing, switch servers or reload the page.',
title:
context === 'switch' ? 'Could not switch the shared source' : '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();
@@ -250,6 +383,15 @@ type UseScreensharePublisherOptions = {
streamKey?: string | null;
};
type PublisherIssue = {
context: PublisherIssueContext;
description: string;
title: string;
tone: 'warning' | 'destructive';
};
type PublisherIssueContext = 'publish' | 'start' | 'switch' | 'warning';
type ScreenCaptureOptions = DisplayMediaStreamOptions & {
monitorTypeSurfaces?: 'include' | 'exclude';
selfBrowserSurface?: 'include' | 'exclude';