diff --git a/apps/web/src/app/(ui)/(protected)/stream/page.tsx b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
index fed9623..c6b5094 100644
--- a/apps/web/src/app/(ui)/(protected)/stream/page.tsx
+++ b/apps/web/src/app/(ui)/(protected)/stream/page.tsx
@@ -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 (
-
-
- Start a screenshare stream, then switch windows, tabs, or displays without ending the
- broadcast.
-
+
+ {/* Video Stage */}
+
+
+
+
+
-
-
+ {!hasPreview && (
+
+
+
+
+
+
+ Ready to livestream
+
+
+ Select a tab, window, or display to preview.
+
+
+
+ )}
-
-
Server
-
+ {(isPreviewingSource || isStarting || isSwitchingSource) && (
+
+
+
+ {isPreviewingSource
+ ? hasPreview
+ ? 'Updating preview...'
+ : 'Preparing preview...'
+ : isStarting
+ ? 'Starting broadcast...'
+ : 'Switching source...'}
+
+
+ )}
+
+
+
+ {isLive && }
+ {statusLabel}
+
+
+
+
- {!hasChannels && !isLoadingChannels ? (
-
- You need at least one channel before you can publish.
-
- ) : null}
+ {(streamKeyError || primaryIssue) && (
+
+ {streamKeyError ? (
+
window.location.reload()} size="sm" variant="outline">
+
+ Reload page
+
+ }
+ description={getStreamKeyErrorDescription(streamKeyError.message)}
+ icon={CircleAlert}
+ title="Could not load the stream key"
+ tone="destructive"
+ />
+ ) : null}
-
-
-
-
-
-
-
{statusMeta.title}
-
{statusMeta.badgeLabel}
-
-
{statusMeta.description}
+ {primaryIssue ? (
+
+ {!isSessionActive && primaryIssue.context === 'preview' ? (
+
+ ) : null}
+
+ {!isSessionActive &&
+ primaryIssue.context !== 'warning' &&
+ primaryIssue.context !== 'preview' ? (
+
+ ) : null}
+
+ {primaryIssue.context === 'switch' && isLive ? (
+
+ ) : null}
+
+ {isSessionActive && primaryIssue.context !== 'warning' ? (
+
+ ) : null}
+
+ }
+ description={primaryIssue.description}
+ icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
+ title={primaryIssue.title}
+ tone={primaryIssue.tone}
+ />
+ ) : null}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!hasChannels && !isLoadingChannels ? (
+
Create a channel to stream.
+ ) : null}
-
-
- {streamKeyError ? (
-
window.location.reload()} size="sm" variant="outline">
-
- Reload page
-
- }
- description={getStreamKeyErrorDescription(streamKeyError.message)}
- icon={CircleAlert}
- title="Could not load the stream key"
- tone="destructive"
- />
- ) : null}
-
- {primaryIssue ? (
-
- {!isSessionActive && primaryIssue.context !== 'warning' ? (
-
- ) : null}
-
- {primaryIssue.context === 'switch' && isLive ? (
+ {/* Right: Actions */}
+
+ {!isSessionActive ? (
+
- ) : null}
- {isSessionActive && primaryIssue.context !== 'warning' ? (
-
+ ) : (
+
+
+
+ Switch
- ) : null}
- >
- }
- description={primaryIssue.description}
- icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert}
- title={primaryIssue.title}
- tone={primaryIssue.tone}
- />
- ) : null}
-
-
-
-
- Start
-
-
- Change source
-
-
- Stop
-
+
+
+ Stop
+
+
+ )}
+
+
);
}
-function ActionPanel({ actions, description, icon: Icon, title, tone }: ActionPanelProps) {
+function AlertCard({ actions, description, icon: Icon, title, tone }: AlertCardProps) {
const isWarning = tone === 'warning';
return (
-
-
+
-
{title}
+
{title}
{description}
-
- {actions ? {actions}
: null}
-
-
+ {actions ? {actions}
: null}
+
+
);
}
-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';
diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts
index ab33dbe..d17f0f7 100644
--- a/apps/web/src/lib/hooks/useScreensharePublisher.ts
+++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts
@@ -31,6 +31,7 @@ export function useScreensharePublisher({
const captureCleanupRef = useRef<(() => void) | null>(null);
const publisherRef = useRef(null);
const [publishState, setPublishState] = useState('idle');
+ const [hasPreview, setHasPreview] = useState(false);
const [issue, setIssue] = useState(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 {
);
}
-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';