feat(bs): redesign ui and add preview

This commit is contained in:
2026-04-23 20:20:06 +02:00
parent 90d73275b2
commit 9e60e1dfe2
2 changed files with 316 additions and 197 deletions

View File

@@ -5,13 +5,17 @@ import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
AlertTriangle,
CheckCircle2,
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';
@@ -44,13 +48,16 @@ export default function Page() {
const {
browserWarning,
changeSource,
hasPreview,
issue,
isLive,
isPreviewReady,
isPreviewingSource,
isSessionActive,
isStarting,
isSwitchingSource,
publishState,
previewRef,
previewSource,
startPublishing,
stopPublishing,
} = useScreensharePublisher({
@@ -62,9 +69,12 @@ export default function Page() {
const hasChannels = ownedChannels.length > 0;
const hasServerOptions = serverOptions.length > 0;
const canStartPublishing =
!isSessionActive && Boolean(selectedChannel) && Boolean(streamKey) && !isLoadingStreamKey;
!isSessionActive &&
!isPreviewingSource &&
Boolean(selectedChannel) &&
Boolean(streamKey) &&
!isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
const statusMeta = getStatusMeta(publishState);
const primaryIssue = issue ?? browserWarning;
useEffect(() => {
@@ -77,219 +87,290 @@ export default function Page() {
}
}, [isSessionActive, ownedChannels, selectedChannel]);
const statusLabel = isLive
? 'LIVE'
: isSwitchingSource
? 'Switching'
: isStarting
? 'Connecting'
: isPreviewingSource
? hasPreview
? 'Updating Preview'
: 'Preparing Preview'
: isPreviewReady
? 'Preview'
: 'Ready';
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Start a screenshare stream, then switch windows, tabs, or displays without ending the
broadcast.
</p>
<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"
/>
<div className="grid gap-4 md:grid-cols-[220px_220px]">
<div className="space-y-2">
<p className="text-sm font-medium">Channel</p>
<ChannelSelect
channelList={ownedChannels}
disabled={isSessionActive || isLoadingChannels || !hasChannels}
placeholder={channelPlaceholder}
value={selectedChannel || undefined}
onSelect={setSelectedChannel}
triggerClassName="w-full"
/>
</div>
{!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>
)}
<div className="space-y-2">
<p className="text-sm font-medium">Server</p>
<Select
value={selectedRegion}
onValueChange={(value) => setSelectedRegion(value as MediaMTXRegion)}
disabled={isSessionActive || !hasServerOptions}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select server" />
</SelectTrigger>
<SelectContent>
{serverOptions.map((server) => (
<SelectItem key={server.value} value={server.value}>
{server.label} {server.emoji}
</SelectItem>
))}
</SelectContent>
</Select>
{(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>
{!hasChannels && !isLoadingChannels ? (
<p className="text-sm text-muted-foreground">
You need at least one channel before you can publish.
</p>
) : null}
{(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}
<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>
{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}>
{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>
</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 ? (
{/* Right: Actions */}
<div className="flex flex-wrap items-center justify-end gap-2">
{!isSessionActive ? (
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={changeSource}
disabled={isSwitchingSource}
loading={isSwitchingSource}
size="sm"
variant="secondary"
onClick={previewSource}
disabled={isPreviewingSource}
loading={isPreviewingSource}
size="default"
>
Try switching again
<Monitor className="mr-2 h-4 w-4" />
{hasPreview ? 'Change Preview' : 'Preview'}
</Button>
) : null}
{isSessionActive && primaryIssue.context !== 'warning' ? (
<Button onClick={stopPublishing} size="sm" variant="outline">
Stop stream
{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>
) : null}
</>
}
description={primaryIssue.description}
icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
title={primaryIssue.title}
tone={primaryIssue.tone}
/>
) : null}
<video
ref={previewRef}
autoPlay
muted
playsInline
className="aspect-video w-full rounded-md bg-black"
/>
<div className="flex gap-2">
<Button onClick={startPublishing} disabled={!canStartPublishing} loading={isStarting}>
Start
</Button>
<Button
variant="outline"
onClick={changeSource}
disabled={!isLive}
loading={isSwitchingSource}
>
Change source
</Button>
<Button onClick={stopPublishing} disabled={!isSessionActive || isSwitchingSource}>
Stop
</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 ActionPanel({ actions, description, icon: Icon, title, tone }: ActionPanelProps) {
function AlertCard({ actions, description, icon: Icon, title, tone }: AlertCardProps) {
const isWarning = tone === 'warning';
return (
<div
className={
<Card
className={cn(
'overflow-hidden border-l-4 shadow-xl backdrop-blur-md',
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'
}
? 'border-l-amber-500 bg-amber-500/[0.03]'
: 'border-l-destructive bg-destructive/[0.03]'
)}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<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={`mt-0.5 h-4 w-4 shrink-0 ${isWarning ? 'text-amber-500' : 'text-destructive'}`}
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-medium">{title}</p>
<p className="text-sm font-semibold">{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>
{actions ? <div className="flex flex-wrap gap-2 md:shrink-0">{actions}</div> : null}
</CardContent>
</Card>
);
}
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.';
@@ -302,12 +383,10 @@ function getStreamKeyErrorDescription(message: string) {
return 'Refresh the page and try again. If it keeps failing, check channel settings and server config.';
}
type ActionPanelProps = {
type AlertCardProps = {
actions?: ReactNode;
description: string;
icon: LucideIcon;
title: string;
tone: 'warning' | 'destructive';
};
type PublishState = 'idle' | 'connecting' | 'live' | 'switching';

View File

@@ -31,6 +31,7 @@ export function useScreensharePublisher({
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(), []);
@@ -49,6 +50,7 @@ export function useScreensharePublisher({
detachCaptureCleanup();
stopTracks(captureStreamRef.current);
captureStreamRef.current = null;
setHasPreview(false);
setPreviewStream(null);
}, [detachCaptureCleanup, setPreviewStream]);
@@ -97,6 +99,7 @@ export function useScreensharePublisher({
detachCaptureCleanup();
captureStreamRef.current = nextStream;
setHasPreview(true);
setPreviewStream(nextStream);
attachCaptureStopListener(nextStream);
stopTracks(previousStream);
@@ -104,6 +107,21 @@ export function useScreensharePublisher({
[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({
@@ -130,9 +148,12 @@ export function useScreensharePublisher({
setPublishState('connecting');
const videoCodec = await getPreferredVideoCodec();
const stream = await requestCaptureStream();
let stream = captureStreamRef.current;
commitCaptureStream(stream);
if (!stream) {
stream = await requestCaptureStream();
commitCaptureStream(stream);
}
const publisher = new MediaMTXWebRTCPublisher({
url: getWhipUrl(channelName, region),
@@ -163,11 +184,11 @@ export function useScreensharePublisher({
publisherRef.current = publisher;
} catch (err) {
disposeCurrentSession();
setPublishState('idle');
closePublisher();
setPublishState(captureStreamRef.current ? 'preview' : 'idle');
setIssue(classifyPublisherIssue(err, 'start'));
}
}, [channelName, commitCaptureStream, disposeCurrentSession, region, streamKey]);
}, [channelName, closePublisher, commitCaptureStream, region, streamKey]);
const changeSource = useCallback(async () => {
const publisher = publisherRef.current;
@@ -202,13 +223,18 @@ export function useScreensharePublisher({
return {
browserWarning,
changeSource,
hasPreview,
issue,
isLive: publishState === 'live',
isSessionActive: publishState !== 'idle',
isPreviewReady: publishState === 'preview',
isPreviewingSource: publishState === 'previewing',
isSessionActive:
publishState === 'connecting' || publishState === 'live' || publishState === 'switching',
isStarting: publishState === 'connecting',
isSwitchingSource: publishState === 'switching',
publishState,
previewRef,
previewSource,
startPublishing,
stopPublishing,
};
@@ -235,7 +261,11 @@ function getErrorMessage(error: unknown, fallback: string) {
function classifyPublisherIssue(error: unknown, context: PublisherIssueContext): PublisherIssue {
const message = getErrorMessage(
error,
context === 'switch' ? 'Failed to change screenshare source' : 'Failed to start publishing'
context === 'switch'
? 'Failed to change screenshare source'
: context === 'preview'
? 'Failed to preview the selected source'
: 'Failed to start publishing'
);
const normalizedMessage = message.toLowerCase();
@@ -245,11 +275,15 @@ function classifyPublisherIssue(error: unknown, context: PublisherIssueContext):
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.',
: 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'
: 'Screen-share permission was denied',
: context === 'preview'
? 'Preview permission was denied'
: 'Screen-share permission was denied',
tone: 'warning',
};
}
@@ -323,9 +357,15 @@ function classifyPublisherIssue(error: unknown, context: PublisherIssueContext):
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.',
: 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' : 'Could not start the stream',
context === 'switch'
? 'Could not switch the shared source'
: context === 'preview'
? 'Could not load the preview'
: 'Could not start the stream',
tone: 'destructive',
};
}
@@ -375,7 +415,7 @@ async function getPreferredVideoCodec(): Promise<string> {
);
}
type PublishState = 'idle' | 'connecting' | 'live' | 'switching';
type PublishState = 'idle' | 'previewing' | 'preview' | 'connecting' | 'live' | 'switching';
type UseScreensharePublisherOptions = {
channelName: string;
@@ -390,7 +430,7 @@ type PublisherIssue = {
tone: 'warning' | 'destructive';
};
type PublisherIssueContext = 'publish' | 'start' | 'switch' | 'warning';
type PublisherIssueContext = 'preview' | 'publish' | 'start' | 'switch' | 'warning';
type ScreenCaptureOptions = DisplayMediaStreamOptions & {
monitorTypeSurfaces?: 'include' | 'exclude';