support huddles #106
Vendored
+8
@@ -2,6 +2,13 @@ import type { LinkMetadata } from './lib/link-metadata';
|
|||||||
import type { AutoplayPayload } from './lib/autoplay-ipc';
|
import type { AutoplayPayload } from './lib/autoplay-ipc';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
interface ScreenSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnailDataUrl: string;
|
||||||
|
appIconDataUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
electronWindow: {
|
electronWindow: {
|
||||||
minimize: () => void;
|
minimize: () => void;
|
||||||
@@ -13,6 +20,7 @@ declare global {
|
|||||||
};
|
};
|
||||||
electronHuddle: {
|
electronHuddle: {
|
||||||
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => () => void;
|
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => () => void;
|
||||||
|
getScreenSources: () => Promise<ScreenSource[]>;
|
||||||
};
|
};
|
||||||
electronAutoplay: {
|
electronAutoplay: {
|
||||||
play: (payload: AutoplayPayload) => void;
|
play: (payload: AutoplayPayload) => void;
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import '@livekit/components-styles';
|
import '@livekit/components-styles';
|
||||||
|
|
||||||
import { LiveKitRoom, VideoConference } from '@livekit/components-react';
|
import {
|
||||||
import { useState, useEffect } from 'react';
|
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() {
|
export function HuddleApp() {
|
||||||
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
|
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
|
||||||
@@ -35,7 +56,153 @@ export function HuddleApp() {
|
|||||||
data-lk-theme="default"
|
data-lk-theme="default"
|
||||||
style={{ height: '100vh' }}
|
style={{ height: '100vh' }}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<HuddleContent />
|
||||||
</LiveKitRoom>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-1
@@ -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 path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||||
@@ -156,6 +156,19 @@ ipcMain.on('window:close-huddle', () => {
|
|||||||
huddleWindow?.close();
|
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
|
// Autoplay IPC handlers
|
||||||
ipcMain.on('autoplay:play', (_event, payload) => {
|
ipcMain.on('autoplay:play', (_event, payload) => {
|
||||||
if (!autoplayWindow) createAutoplayWindow();
|
if (!autoplayWindow) createAutoplayWindow();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('electronHuddle', {
|
|||||||
ipcRenderer.on('huddle:connect', handler);
|
ipcRenderer.on('huddle:connect', handler);
|
||||||
return () => { ipcRenderer.removeListener('huddle:connect', handler); };
|
return () => { ipcRenderer.removeListener('huddle:connect', handler); };
|
||||||
},
|
},
|
||||||
|
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
|
||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAutoplay', {
|
contextBridge.exposeInMainWorld('electronAutoplay', {
|
||||||
|
|||||||
Reference in New Issue
Block a user