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', {