support huddles (#106)

* add token endpoint for livekit

* fix: invalid type passed to hook

* fix: inject livekit env variables for orion

* fix: show controls indicator above stream # shortcut

* return livekit server url from api

* simple huddle implementation with streams

* set human name in livekit room context

* simplify deployment tooling

* join huddle with audio automatically

* feat: show when there is an active huddle

This introduces a webhook which listens to events from livekit and
updates our firestore stream particle. It keeps the client simple,
reacting to changes to firestore docs.

* use headphones icon for huddles

* fix: screenshare not working in electron

The default VideoConference component from livekit doesn't support
screenshare in electron. This attempts to compose our own layout with
livekit components ourselves and introduces our own flow for
screenshare.
This commit was merged in pull request #106.
This commit is contained in:
Arjun Patel
2026-04-01 10:32:15 -07:00
committed by GitHub
parent 47e2a9baa4
commit 4d1ad717ad
32 changed files with 1358 additions and 243 deletions
+208
View File
@@ -0,0 +1,208 @@
import '@livekit/components-styles';
import {
CarouselLayout,
Chat,
ControlBar,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
LiveKitRoom,
ParticipantTile,
RoomAudioRenderer,
StartAudio,
isTrackReference,
useCreateLayoutContext,
usePinnedTracks,
useRoomContext,
useTracks,
ScreenShareIcon,
ScreenShareStopIcon,
} from '@livekit/components-react';
import { RoomEvent, Track } from 'livekit-client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { ScreenPicker } from './ScreenPicker';
export function HuddleApp() {
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
useEffect(() => {
return window.electronHuddle.onConnect((data) => {
setConnection(data);
});
}, []);
const handleDisconnected = () => {
window.electronWindow.closeHuddle();
};
if (!connection) {
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<p className="text-muted-foreground text-sm">Connecting to huddle...</p>
</div>
);
}
return (
<LiveKitRoom
token={connection.token}
serverUrl={connection.serverUrl}
connect={true}
audio={true}
video={false}
onDisconnected={handleDisconnected}
data-lk-theme="default"
style={{ height: '100vh' }}
>
<HuddleContent />
</LiveKitRoom>
);
}
function HuddleContent() {
const room = useRoomContext();
const [showPicker, setShowPicker] = useState(false);
const [isSharing, setIsSharing] = useState(false);
const [widgetState, setWidgetState] = useState({ showChat: false, unreadMessages: 0 });
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
);
const layoutContext = useCreateLayoutContext();
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((t) => t.publication.source === Track.Source.ScreenShare);
const pinnedTracks = usePinnedTracks(layoutContext);
const focusTrack = pinnedTracks?.[0];
const remainingTracks = tracks.filter(
(t) =>
!focusTrack ||
t.participant.identity !== focusTrack.participant.identity ||
t.source !== focusTrack.source,
);
// Auto-pin/unpin screen share tracks (same logic as VideoConference)
const lastAutoPin = useRef<{ trackSid: string } | null>(null);
useEffect(() => {
if (
screenShareTracks.some((t) => t.publication.isSubscribed) &&
lastAutoPin.current === null
) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
lastAutoPin.current = { trackSid: screenShareTracks[0].publication.trackSid };
} else if (
lastAutoPin.current &&
!screenShareTracks.some(
(t) => t.publication.trackSid === lastAutoPin.current?.trackSid,
)
) {
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
lastAutoPin.current = null;
}
}, [
screenShareTracks.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join(),
]);
// Sync isSharing state with actual track state
useEffect(() => {
const onLocalTrackUnpublished = () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (!pub) setIsSharing(false);
};
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
return () => {
room.off(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
};
}, [room]);
const handleScreenShare = useCallback(
async (sourceId: string) => {
setShowPicker(false);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
},
} as MediaTrackConstraints,
});
const videoTrack = stream.getVideoTracks()[0];
await room.localParticipant.publishTrack(videoTrack, {
source: Track.Source.ScreenShare,
});
setIsSharing(true);
videoTrack.onended = () => stopScreenShare();
} catch (err) {
console.error('Failed to start screen share:', err);
}
},
[room],
);
const stopScreenShare = useCallback(async () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (pub?.track) {
await room.localParticipant.unpublishTrack(pub.track.mediaStreamTrack);
pub.track.stop();
}
setIsSharing(false);
}, [room]);
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext} onWidgetChange={setWidgetState}>
<div className="lk-video-conference-inner">
{focusTrack ? (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={remainingTracks}>
<ParticipantTile />
</CarouselLayout>
<FocusLayout trackRef={focusTrack} />
</FocusLayoutContainer>
</div>
) : (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
)}
<div className="lk-control-bar">
<ControlBar
controls={{ screenShare: false, chat: true }}
style={{ border: 'none', padding: 0, maxHeight: 'none' }}
/>
<button
className="lk-button"
onClick={isSharing ? stopScreenShare : () => setShowPicker(true)}
aria-label={isSharing ? 'Stop sharing' : 'Share screen'}
>
{isSharing ? <ScreenShareStopIcon /> : <ScreenShareIcon />}
{isSharing ? 'Stop share' : 'Share screen'}
</button>
</div>
</div>
<Chat style={{ display: widgetState.showChat ? 'grid' : 'none' }} />
</LayoutContextProvider>
<RoomAudioRenderer />
<StartAudio label="Allow audio" />
{showPicker && (
<ScreenPicker
onSelect={handleScreenShare}
onCancel={() => setShowPicker(false)}
/>
)}
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
interface ScreenPickerProps {
onSelect: (sourceId: string) => void;
onCancel: () => void;
}
export function ScreenPicker({ onSelect, onCancel }: ScreenPickerProps) {
const [sources, setSources] = useState<ScreenSource[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
window.electronHuddle.getScreenSources().then((result) => {
setSources(result);
setLoading(false);
});
}, []);
const screens = sources.filter((s) => s.id.startsWith('screen:'));
const windows = sources.filter((s) => s.id.startsWith('window:'));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<h2 className="text-base font-medium text-zinc-100">Share your screen</h2>
<button
onClick={onCancel}
className="text-zinc-400 hover:text-zinc-200"
>
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading ? (
<p className="text-center text-sm text-zinc-400">Loading sources</p>
) : (
<>
{screens.length > 0 && (
<SourceSection
title="Screens"
sources={screens}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
{windows.length > 0 && (
<SourceSection
title="Windows"
sources={windows}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
<button
onClick={onCancel}
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
disabled={!selectedId}
onClick={() => selectedId && onSelect(selectedId)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
>
Share
</button>
</div>
</div>
</div>
);
}
function SourceSection({
title,
sources,
selectedId,
onSelect,
}: {
title: string;
sources: ScreenSource[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<div className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">{title}</h3>
<div className="grid grid-cols-3 gap-3">
{sources.map((source) => (
<button
key={source.id}
onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id
? 'border-blue-500 bg-zinc-800'
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
}`}
>
<img
src={source.thumbnailDataUrl}
alt={source.name}
className="aspect-video w-full object-cover"
/>
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">{source.name}</p>
</button>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client';
import { HuddleApp } from '@/huddle/HuddleApp';
import { HuddleApp } from './HuddleApp';
import '@/styles/globals.css';
const root = createRoot(document.getElementById('root')!);