support huddles (#106)
* add token endpoint for livekit * fix: invalid type passed to hook * fix: inject livekit env variables for orion * fix: show controls indicator above stream # shortcut * return livekit server url from api * simple huddle implementation with streams * set human name in livekit room context * simplify deployment tooling * join huddle with audio automatically * feat: show when there is an active huddle This introduces a webhook which listens to events from livekit and updates our firestore stream particle. It keeps the client simple, reacting to changes to firestore docs. * use headphones icon for huddles * 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 was merged in pull request #106.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
DepotObjectSchema,
|
||||
GetLivekitTokenResponseSchema,
|
||||
HumanSchema,
|
||||
ListInvitationsResponseSchema,
|
||||
ListNetworksResponseSchema,
|
||||
@@ -211,6 +212,12 @@ class ApiClient {
|
||||
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
|
||||
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data);
|
||||
}
|
||||
|
||||
// --- LiveKit ---
|
||||
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
|
||||
@@ -184,6 +184,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
// Timestamp of the most recent child particle
|
||||
// used for sorting streams by recent activity without needing to query subcollections
|
||||
last_child_created_at: z.coerce.date().optional(),
|
||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||
huddle_active_participants: z.array(z.string()).optional(),
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||
@@ -209,6 +211,14 @@ export function isContainerType(type: ParticleType): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
}
|
||||
|
||||
// --- LiveKit types ---
|
||||
|
||||
export const GetLivekitTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
server_url: z.string(),
|
||||
});
|
||||
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
||||
|
||||
// --- Auth types ---
|
||||
|
||||
const RequestCodeRequestSchema = z.object({
|
||||
|
||||
Vendored
+12
-1
@@ -2,15 +2,26 @@ 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;
|
||||
maximize: () => void;
|
||||
fullscreen: () => void;
|
||||
close: () => void;
|
||||
openHuddle: () => void;
|
||||
openHuddle: (data: { token: string; serverUrl: string }) => void;
|
||||
closeHuddle: () => void;
|
||||
};
|
||||
electronHuddle: {
|
||||
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => () => void;
|
||||
getScreenSources: () => Promise<ScreenSource[]>;
|
||||
};
|
||||
electronAutoplay: {
|
||||
play: (payload: AutoplayPayload) => void;
|
||||
dismiss: () => void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Video, Mic, Paperclip } from "lucide-react";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
interface ControlsIndicatorProps {
|
||||
type: "reply" | "new";
|
||||
@@ -12,7 +13,8 @@ export default function ControlsIndicator({
|
||||
attachmentCount = 0,
|
||||
showEscape = false,
|
||||
onOpenAttachments,
|
||||
}: ControlsIndicatorProps) {
|
||||
children
|
||||
}: PropsWithChildren<ControlsIndicatorProps>) {
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
@@ -82,6 +84,7 @@ export default function ControlsIndicator({
|
||||
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function NetworkRoot() {
|
||||
</div>
|
||||
|
||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||
<div className="pointer-events-auto">
|
||||
<ControlsIndicator type={"new"} />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CircleCheck,
|
||||
StickyNote,
|
||||
Timer,
|
||||
Headphones,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -94,13 +95,17 @@ function StreamRow({
|
||||
const userId = user?.id ?? "";
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network);
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const expiringSoon = useExpiringSoon(
|
||||
particle.last_child_created_at,
|
||||
network?.message_retention_hours ?? 24,
|
||||
);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
@@ -162,6 +167,7 @@ function StreamRow({
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
|
||||
)}
|
||||
>
|
||||
{shortcutKey && (
|
||||
@@ -188,7 +194,13 @@ function StreamRow({
|
||||
>
|
||||
{particle.properties.name}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{expiringSoon && (
|
||||
<Timer className="size-3 text-muted-foreground/60" />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { Timer, Headphones } from "lucide-react";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -27,13 +27,17 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network);
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const expiringSoon = useExpiringSoon(
|
||||
particle.last_child_created_at,
|
||||
network?.message_retention_hours ?? 24,
|
||||
);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
@@ -94,10 +98,14 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
isSelected && "ring-2 ring-ring",
|
||||
hasActiveHuddle && "ring-2 ring-red-500/70",
|
||||
)}
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
)}
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
@@ -141,6 +149,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{expiringSoon && (
|
||||
<Timer className="size-3 text-muted-foreground/60" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useEffectEvent, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
||||
@@ -19,6 +20,7 @@ import { WindowControls } from "@/components/window-controls";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
function getParticleDisplayName(particle: Particle): string {
|
||||
switch (particle.type) {
|
||||
@@ -170,12 +172,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
e.preventDefault();
|
||||
navigate(`/${networkId}`);
|
||||
break;
|
||||
case "h": {
|
||||
e.preventDefault();
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
},
|
||||
[composeActive, next, prev, navigate, networkId],
|
||||
[composeActive, next, prev, navigate, networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
// Click-to-navigate: left 30% = prev, right 70% = next
|
||||
@@ -204,7 +213,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No particles in this stream yet
|
||||
</p>
|
||||
<ControlsIndicator type="reply" showEscape={true} />
|
||||
<ControlsIndicator type="reply" showEscape={true}>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
huddle
|
||||
</span>
|
||||
</ControlsIndicator>
|
||||
<ComposeOverlay
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
@@ -299,10 +315,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
{/* Bottom-center: controls */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3">
|
||||
<ControlsIndicator
|
||||
showEscape={true}
|
||||
type="reply"
|
||||
/>
|
||||
<ControlsIndicator type="reply" showEscape={true}>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
huddle
|
||||
</span>
|
||||
</ControlsIndicator>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -310,6 +330,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||||
const navigate = useNavigate();
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||
|
||||
const handleJoinHuddle = () => {
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drag-region flex flex-row px-4 gap-5 items-center">
|
||||
@@ -336,6 +366,37 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{hasActiveHuddle && (
|
||||
<button
|
||||
onClick={handleJoinHuddle}
|
||||
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
|
||||
>
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
<AvatarGroup>
|
||||
{huddleParticipants.map((humanId) => {
|
||||
const human = network?.humans?.find((h) => h.id === humanId);
|
||||
const initials = human ? getInitials(human.email) : "?";
|
||||
return (
|
||||
<Tooltip key={humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{human?.email_prefix ?? humanId}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<span className="text-xs font-medium text-red-200">Join</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function HuddleApp() {
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [videoOn, setVideoOn] = useState(true);
|
||||
|
||||
const handleLeave = () => {
|
||||
window.electronWindow.closeHuddle();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
{/* Video grid */}
|
||||
<div className="flex flex-1 items-center justify-center gap-4 p-6">
|
||||
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
|
||||
<span className="text-muted-foreground text-sm">You</span>
|
||||
</div>
|
||||
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
|
||||
<span className="text-muted-foreground text-sm">Participant</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls bar */}
|
||||
<div className="flex items-center justify-center gap-3 border-t px-6 py-4">
|
||||
<button
|
||||
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
|
||||
onClick={() => setMicOn(!micOn)}
|
||||
>
|
||||
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5 text-destructive" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
|
||||
onClick={() => setVideoOn(!videoOn)}
|
||||
>
|
||||
{videoOn ? <Video className="size-5" /> : <VideoOff className="size-5 text-destructive" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-destructive p-3 text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||
onClick={handleLeave}
|
||||
>
|
||||
<PhoneOff className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import '@livekit/components-styles';
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
return window.electronHuddle.onConnect((data) => {
|
||||
setConnection(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDisconnected = () => {
|
||||
window.electronWindow.closeHuddle();
|
||||
};
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<p className="text-muted-foreground text-sm">Connecting to huddle...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveKitRoom
|
||||
token={connection.token}
|
||||
serverUrl={connection.serverUrl}
|
||||
connect={true}
|
||||
audio={true}
|
||||
video={false}
|
||||
onDisconnected={handleDisconnected}
|
||||
data-lk-theme="default"
|
||||
style={{ height: '100vh' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HuddleApp } from '@/huddle/HuddleApp';
|
||||
import { HuddleApp } from './HuddleApp';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
|
||||
@@ -62,6 +62,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
)
|
||||
: undefined,
|
||||
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
|
||||
huddle_active_participants: raw.huddle_active_participants ?? undefined,
|
||||
});
|
||||
case "folder":
|
||||
return ParticleSchema.parse({
|
||||
|
||||
+23
-2
@@ -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';
|
||||
@@ -141,13 +141,34 @@ ipcMain.on('window:fullscreen', (event) => {
|
||||
});
|
||||
|
||||
// Secondary window IPC handlers
|
||||
ipcMain.on('window:open-huddle', () => {
|
||||
ipcMain.on('window:open-huddle', (_event, data: { token: string; serverUrl: string }) => {
|
||||
createHuddleWindow();
|
||||
// Send connection data once the huddle window is ready
|
||||
huddleWindow?.webContents.once('did-finish-load', () => {
|
||||
huddleWindow?.webContents.send('huddle:connect', data);
|
||||
});
|
||||
// If already loaded, send immediately
|
||||
if (!huddleWindow?.webContents.isLoading()) {
|
||||
huddleWindow?.webContents.send('huddle:connect', data);
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
+10
-1
@@ -7,10 +7,19 @@ contextBridge.exposeInMainWorld('electronWindow', {
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
fullscreen: () => ipcRenderer.send('window:fullscreen'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
openHuddle: () => ipcRenderer.send('window:open-huddle'),
|
||||
openHuddle: (data: { token: string; serverUrl: string }) => ipcRenderer.send('window:open-huddle', data),
|
||||
closeHuddle: () => ipcRenderer.send('window:close-huddle'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electronHuddle', {
|
||||
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; serverUrl: string }) => callback(data);
|
||||
ipcRenderer.on('huddle:connect', handler);
|
||||
return () => { ipcRenderer.removeListener('huddle:connect', handler); };
|
||||
},
|
||||
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAutoplay', {
|
||||
play: (payload: unknown) => ipcRenderer.send('autoplay:play', payload),
|
||||
dismiss: () => ipcRenderer.send('autoplay:dismiss'),
|
||||
|
||||
Reference in New Issue
Block a user