support huddles #106

Merged
talksik merged 12 commits from huddles into master 2026-04-01 17:32:15 +00:00
5 changed files with 310 additions and 4 deletions
Showing only changes of commit bf873fca84 - Show all commits
+8
View File
@@ -2,6 +2,13 @@ import type { LinkMetadata } from './lib/link-metadata';
import type { AutoplayPayload } from './lib/autoplay-ipc';
declare global {
interface ScreenSource {
id: string;
name: string;
thumbnailDataUrl: string;
appIconDataUrl: string | null;
}
interface Window {
electronWindow: {
minimize: () => void;
@@ -13,6 +20,7 @@ declare global {
};
electronHuddle: {
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => () => void;
getScreenSources: () => Promise<ScreenSource[]>;
};
electronAutoplay: {
play: (payload: AutoplayPayload) => void;
+170 -3
View File
@@ -1,7 +1,28 @@
import '@livekit/components-styles';
import { LiveKitRoom, VideoConference } from '@livekit/components-react';
import { useState, useEffect } from 'react';
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);
@@ -35,7 +56,153 @@ export function HuddleApp() {
data-lk-theme="default"
style={{ height: '100vh' }}
>
<VideoConference />
<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>
);
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, screen, session, shell } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session, shell } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -156,6 +156,19 @@ ipcMain.on('window:close-huddle', () => {
huddleWindow?.close();
});
ipcMain.handle('screen:get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnailDataUrl: source.thumbnail.toDataURL(),
appIconDataUrl: source.appIcon?.toDataURL() ?? null,
}));
});
// Autoplay IPC handlers
ipcMain.on('autoplay:play', (_event, payload) => {
if (!autoplayWindow) createAutoplayWindow();
+1
View File
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('electronHuddle', {
ipcRenderer.on('huddle:connect', handler);
return () => { ipcRenderer.removeListener('huddle:connect', handler); };
},
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
});
contextBridge.exposeInMainWorld('electronAutoplay', {