From bf873fca8466c6dfbfbf1a1d38623a4f7ccd7e06 Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 1 Apr 2026 10:29:57 -0700 Subject: [PATCH] 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. --- js/src/electron.d.ts | 8 ++ js/src/huddle_window/HuddleApp.tsx | 173 +++++++++++++++++++++++++- js/src/huddle_window/ScreenPicker.tsx | 117 +++++++++++++++++ js/src/main.ts | 15 ++- js/src/preload.ts | 1 + 5 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 js/src/huddle_window/ScreenPicker.tsx diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts index 5a9499e..3823276 100644 --- a/js/src/electron.d.ts +++ b/js/src/electron.d.ts @@ -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; }; electronAutoplay: { play: (payload: AutoplayPayload) => void; diff --git a/js/src/huddle_window/HuddleApp.tsx b/js/src/huddle_window/HuddleApp.tsx index db7fdc9..499d8b4 100644 --- a/js/src/huddle_window/HuddleApp.tsx +++ b/js/src/huddle_window/HuddleApp.tsx @@ -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' }} > - + ); } + +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 ( +
+ +
+ {focusTrack ? ( +
+ + + + + + +
+ ) : ( +
+ + + +
+ )} +
+ + +
+
+ +
+ + + {showPicker && ( + setShowPicker(false)} + /> + )} +
+ ); +} diff --git a/js/src/huddle_window/ScreenPicker.tsx b/js/src/huddle_window/ScreenPicker.tsx new file mode 100644 index 0000000..4cfea99 --- /dev/null +++ b/js/src/huddle_window/ScreenPicker.tsx @@ -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([]); + const [selectedId, setSelectedId] = useState(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 ( +
+
+
+

Share your screen

+ +
+ +
+ {loading ? ( +

Loading sources…

+ ) : ( + <> + {screens.length > 0 && ( + + )} + {windows.length > 0 && ( + + )} + + )} +
+ +
+ + +
+
+
+ ); +} + +function SourceSection({ + title, + sources, + selectedId, + onSelect, +}: { + title: string; + sources: ScreenSource[]; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + return ( +
+

{title}

+
+ {sources.map((source) => ( + + ))} +
+
+ ); +} diff --git a/js/src/main.ts b/js/src/main.ts index f8fa678..3886dae 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -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(); diff --git a/js/src/preload.ts b/js/src/preload.ts index a9b72b4..28f9b72 100644 --- a/js/src/preload.ts +++ b/js/src/preload.ts @@ -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', {