mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat(bs): error handling and stuff
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user