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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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')!);
|
||||
|
||||
Reference in New Issue
Block a user