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 */} +
+
+
+
+
- {!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' ? ( - + ) : null} + + +
+ ) : ( +
+ - ) : null} - - } - description={primaryIssue.description} - icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert} - title={primaryIssue.title} - tone={primaryIssue.tone} - /> - ) : null} -
+
); } -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';