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 is contained in:
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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();
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user