(feat): add basic call ui
(feat): add screensharing (incl. broken desktop picker) (qol): add todo
This commit is contained in:
parent
7b5cdca0ff
commit
fbd8ef4fa0
21 changed files with 1167 additions and 380 deletions
|
|
@ -1,15 +1,26 @@
|
|||
import { Card, CardContent } from "@tensamin/ui";
|
||||
import { Card, CardContent, Button } from "@tensamin/ui";
|
||||
import MuteButton from "./buttons/mute";
|
||||
import DeafButton from "./buttons/deaf";
|
||||
import ScreenshareButton from "./buttons/screenshare";
|
||||
import LeaveButton from "./buttons/leave";
|
||||
import { stopWatchingFocusedStream, useCall } from "../store";
|
||||
import InviteButton from "./buttons/invite";
|
||||
|
||||
export default function Actions() {
|
||||
const sharedClasses = "w-14 h-10";
|
||||
const sharedIconSize = 15;
|
||||
const view = useCall((state) => state.view);
|
||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||
const watchedStreamParticipantIds = useCall(
|
||||
(state) => state.watchedStreamParticipantIds,
|
||||
);
|
||||
const isWatchingFocusedStream =
|
||||
view === "focused" &&
|
||||
focusedParticipantId != null &&
|
||||
watchedStreamParticipantIds.includes(focusedParticipantId);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center py-3">
|
||||
<div className="w-full flex justify-center">
|
||||
<Card className="p-1.75">
|
||||
<CardContent className="p-0! flex gap-1.75">
|
||||
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
|
|
@ -18,7 +29,18 @@ export default function Actions() {
|
|||
className={sharedClasses}
|
||||
iconSize={sharedIconSize}
|
||||
/>
|
||||
<LeaveButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
<InviteButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
{isWatchingFocusedStream ? (
|
||||
<Button
|
||||
className="h-10"
|
||||
variant="destructive"
|
||||
onClick={() => stopWatchingFocusedStream()}
|
||||
>
|
||||
Stop watching
|
||||
</Button>
|
||||
) : (
|
||||
<LeaveButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
59
packages/call/src/components/buttons/invite.tsx
Normal file
59
packages/call/src/components/buttons/invite.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useTTP } from "@tensamin/ttp";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { Mail } from "lucide-react";
|
||||
import { sendCallInvite } from "../../store";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
|
||||
export default function InviteButton({
|
||||
className,
|
||||
iconSize,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
}) {
|
||||
const { contacts } = useTTP();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button className={className}>
|
||||
<Mail style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="flex w-40 flex-col gap-0.5 p-1!">
|
||||
{contacts.map((contact) => (
|
||||
<Wrapper
|
||||
key={contact.user_id}
|
||||
userId={contact.user_id}
|
||||
loading={<div>Loading...</div>}
|
||||
component={(user) => (
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void sendCallInvite(contact.user_id).catch((err) => {
|
||||
toast(
|
||||
"error",
|
||||
"Failed to send call invite. Check console for details.",
|
||||
);
|
||||
log(1, "call", "red", "Failed to send call invite", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{user.display}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import { useState } from "react";
|
|||
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
|
||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { useDesktopMedia } from "@tensamin/tauri/context";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { setScreenShareEnabled, useCall } from "../../store";
|
||||
import ScreenShareDialog from "./screenshareDialog";
|
||||
import ScreenShareDialog from "../screenshareDialog";
|
||||
|
||||
export default function ScreenshareButton({
|
||||
className,
|
||||
|
|
@ -16,7 +16,6 @@ export default function ScreenshareButton({
|
|||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { isDesktopTauri } = useDesktopMedia();
|
||||
|
||||
async function startWebShare() {
|
||||
try {
|
||||
|
|
@ -67,13 +66,13 @@ export default function ScreenshareButton({
|
|||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="flex w-52 flex-col gap-2">
|
||||
<PopoverContent className="flex w-40 flex-col gap-2">
|
||||
<Button
|
||||
disabled={isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
|
||||
if (isDesktopTauri) {
|
||||
if (isTauri()) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -81,7 +80,7 @@ export default function ScreenshareButton({
|
|||
void startWebShare();
|
||||
}}
|
||||
>
|
||||
Start sharing
|
||||
Start screenshare
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
|
@ -91,15 +90,13 @@ export default function ScreenshareButton({
|
|||
void stopShare();
|
||||
}}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
<Button disabled variant="outline">
|
||||
Stream quality soon
|
||||
Stop screenshare
|
||||
</Button>
|
||||
<Button variant="outline">Change quality</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isDesktopTauri && (
|
||||
{isTauri() && (
|
||||
<ScreenShareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
|
|
|
|||
165
packages/call/src/components/modals/base.tsx
Normal file
165
packages/call/src/components/modals/base.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// show speaking ring
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import { focusParticipant, setCallView, useCall } from "../../store";
|
||||
import { type Participant } from "livekit-client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser, type User } from "@tensamin/user/context";
|
||||
import VideoViewer from "../videoViewer";
|
||||
import { Loader2, MicOff, Monitor } from "lucide-react";
|
||||
|
||||
function TransparentButton({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-6 px-1.5 bg-black/80 rounded-sm flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overlay({
|
||||
type,
|
||||
user,
|
||||
participant,
|
||||
}: {
|
||||
type: "user" | "stream";
|
||||
user: User;
|
||||
participant: Participant;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-auto flex items-center gap-2">
|
||||
{type === "user" && (
|
||||
<>
|
||||
{!participant.isMicrophoneEnabled && (
|
||||
<TransparentButton>
|
||||
<MicOff className="size-3.5" />
|
||||
</TransparentButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{type === "stream" && (
|
||||
<TransparentButton>
|
||||
<Monitor className="size-3.5" />
|
||||
</TransparentButton>
|
||||
)}
|
||||
<TransparentButton>
|
||||
<p className="text-sm">{user.display}</p>
|
||||
</TransparentButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Base({
|
||||
participant,
|
||||
type,
|
||||
flush = false,
|
||||
}: {
|
||||
participant: Participant | undefined;
|
||||
type: "user" | "stream";
|
||||
flush?: boolean;
|
||||
}) {
|
||||
const { get } = useUser();
|
||||
|
||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||
const view = useCall((state) => state.view);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const participantId = Number(participant?.identity);
|
||||
|
||||
if (
|
||||
!participant ||
|
||||
!Number.isInteger(participantId) ||
|
||||
participantId <= 0
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
void get(participantId).then((nextUser) => {
|
||||
if (active) {
|
||||
setUser(nextUser);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [participant, get]);
|
||||
|
||||
if (!participant || !user) {
|
||||
return (
|
||||
<div
|
||||
className={`aspect-video w-full animate-pulse bg-muted ${
|
||||
flush ? "rounded-none" : "rounded-md"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (view === "grid") {
|
||||
focusParticipant(user.user_id);
|
||||
} else {
|
||||
if (user.user_id === focusedParticipantId) {
|
||||
setCallView("grid");
|
||||
} else {
|
||||
focusParticipant(user.user_id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`relative aspect-video w-full border-2 ${
|
||||
flush ? "rounded-none border-x-0" : "rounded-md"
|
||||
}`}
|
||||
>
|
||||
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
||||
{view === "grid" && (
|
||||
<Overlay type={type} user={user} participant={participant} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex items-center justify-center ${
|
||||
flush ? "rounded-none" : "rounded-sm"
|
||||
}`}
|
||||
>
|
||||
{/* Detect video / user and place here */}
|
||||
|
||||
{type === "stream" &&
|
||||
Array.from(participant.videoTrackPublications.values()).map(
|
||||
(publication) =>
|
||||
publication.isSubscribed && publication.track ? (
|
||||
<VideoViewer
|
||||
flush={flush}
|
||||
participantId={participant.identity}
|
||||
publication={publication}
|
||||
/>
|
||||
) : (
|
||||
<Loader2 className="animate-spin" />
|
||||
),
|
||||
)}
|
||||
|
||||
{type === "user" && <p>{user.display}</p>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Stop Watching</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function SmallUser() {
|
||||
return <div>Small User</div>;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function SmallVideo() {
|
||||
return <div>Small Video</div>;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function BigUser() {
|
||||
return <div>Big User</div>;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function BigVideo() {
|
||||
return <div>Big Video</div>;
|
||||
}
|
||||
0
packages/call/src/components/modals/user.tsx
Normal file
0
packages/call/src/components/modals/user.tsx
Normal file
0
packages/call/src/components/modals/video.tsx
Normal file
0
packages/call/src/components/modals/video.tsx
Normal file
|
|
@ -24,10 +24,7 @@ import {
|
|||
import { toast } from "@tensamin/shared/log";
|
||||
import { AppWindow, Loader2, MonitorUp } from "lucide-react";
|
||||
import type { ScreenShareCaptureOptions } from "livekit-client";
|
||||
import {
|
||||
setScreenShareEnabled,
|
||||
startLinuxDesktopScreenShare,
|
||||
} from "../../store";
|
||||
import { setScreenShareEnabled, startLinuxDesktopScreenShare } from "../store";
|
||||
|
||||
const NONE_AUDIO_OUTPUT = "__none__";
|
||||
|
||||
|
|
@ -37,9 +34,9 @@ function buildScreenShareOptions(
|
|||
selectedAudioOutputId: string,
|
||||
shareAudio: boolean,
|
||||
): ScreenShareCaptureOptions {
|
||||
const wantsAudio = capabilities.usesPipeWireAudioPicker
|
||||
const wantsAudio = capabilities.showAudioOutputSelector
|
||||
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
|
||||
: capabilities.supportsReliableSystemAudio && shareAudio;
|
||||
: capabilities.hasReliableSystemAudio && shareAudio;
|
||||
|
||||
return {
|
||||
audio: wantsAudio
|
||||
|
|
@ -76,9 +73,9 @@ export default function ScreenShareDialog({
|
|||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sources, setSources] = useState<DesktopScreenShareSource[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<DesktopScreenShareAudioOutput[]>(
|
||||
[],
|
||||
);
|
||||
const [audioOutputs, setAudioOutputs] = useState<
|
||||
DesktopScreenShareAudioOutput[]
|
||||
>([]);
|
||||
const [capabilities, setCapabilities] =
|
||||
useState<DesktopScreenShareCapabilities | null>(null);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
|
|
@ -107,7 +104,7 @@ export default function ScreenShareDialog({
|
|||
setSources(nextSources);
|
||||
setCapabilities(nextCapabilities);
|
||||
|
||||
if (nextCapabilities.usesPipeWireAudioPicker) {
|
||||
if (nextCapabilities.showAudioOutputSelector) {
|
||||
const nextOutputs = await listScreenShareAudioOutputs();
|
||||
|
||||
if (!active) {
|
||||
|
|
@ -197,7 +194,7 @@ export default function ScreenShareDialog({
|
|||
<DialogHeader className="p-4 pb-3">
|
||||
<DialogTitle>Share your screen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the window or display you want to share with the call.
|
||||
Choose a window or display you want to share.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -238,11 +235,11 @@ export default function ScreenShareDialog({
|
|||
|
||||
{!loading && sources.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No windows or displays are currently available for capture.
|
||||
No windows or displays found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{capabilities?.usesPipeWireAudioPicker ? (
|
||||
{capabilities?.showAudioOutputSelector ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Share audio output</Label>
|
||||
<Select
|
||||
|
|
@ -252,43 +249,46 @@ export default function ScreenShareDialog({
|
|||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="None" />
|
||||
<SelectValue>
|
||||
{selectedAudioOutputId === NONE_AUDIO_OUTPUT
|
||||
? "None"
|
||||
: (audioOutputs.find(
|
||||
(output) => output.id === selectedAudioOutputId,
|
||||
)?.name ?? "None")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
|
||||
{audioOutputs.map((output) => (
|
||||
<SelectItem key={output.id} value={output.id}>
|
||||
{output.name}
|
||||
{output.isDefault
|
||||
? `${output.name} (Default)`
|
||||
: output.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a PipeWire output to try sharing system audio with your
|
||||
screen.
|
||||
</p>
|
||||
</div>
|
||||
) : capabilities?.supportsSystemAudioSwitch ? (
|
||||
) : capabilities?.showAudioSwitch ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share system audio alongside your screen when the runtime can
|
||||
provide it.
|
||||
Share system audio alongside your screen when the runtime
|
||||
can provide it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-system-audio"
|
||||
checked={shareAudio}
|
||||
disabled={!capabilities.supportsReliableSystemAudio}
|
||||
disabled={!capabilities.hasReliableSystemAudio}
|
||||
onCheckedChange={setShareAudio}
|
||||
/>
|
||||
</div>
|
||||
{!capabilities.supportsReliableSystemAudio && (
|
||||
{!capabilities.hasReliableSystemAudio && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
System audio sharing is not available on this platform/runtime
|
||||
yet.
|
||||
System audio sharing is not available on this platform.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -297,17 +297,21 @@ export default function ScreenShareDialog({
|
|||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading available share targets...
|
||||
Loading sources...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="m-0! p-2!">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isScreensharing && (
|
||||
<Button variant="destructive" disabled={loading} onClick={stopSharing}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={loading}
|
||||
onClick={stopSharing}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -5,24 +5,23 @@ import { useEffect, useState } from "react";
|
|||
export default function TopBar() {
|
||||
const { get } = useUser();
|
||||
const room = useCall((state) => state.room);
|
||||
const view = useCall((state) => state.view);
|
||||
|
||||
const userIds = Array.from(room.remoteParticipants.values(), (participant) =>
|
||||
Number(participant.identity),
|
||||
);
|
||||
const userIds = Array.from(room.remoteParticipants.values(), (participant) => {
|
||||
const participantId = Number(participant.identity);
|
||||
return Number.isInteger(participantId) && participantId > 0
|
||||
? participantId
|
||||
: null;
|
||||
}).filter((participantId): participantId is number => participantId != null);
|
||||
const userIdsKey = userIds.join(",");
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
void Promise.all(userIds.map((id) => get(id)))
|
||||
void Promise.all(ids.map((id) => get(id)))
|
||||
.then((users) => {
|
||||
if (!active) {
|
||||
return;
|
||||
|
|
@ -41,7 +40,7 @@ export default function TopBar() {
|
|||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [get, userIdsKey, userIds]);
|
||||
}, [get, userIdsKey]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-between h-12">
|
||||
|
|
@ -50,6 +49,7 @@ export default function TopBar() {
|
|||
<div key={user.user_id}>{user.display}</div>
|
||||
))}
|
||||
</div>
|
||||
{view}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
38
packages/call/src/components/videoViewer.tsx
Normal file
38
packages/call/src/components/videoViewer.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { VideoTrack, useParticipantTracks } from "@livekit/components-react";
|
||||
import { TrackPublication } from "livekit-client";
|
||||
import { useCall } from "../store";
|
||||
import { cn } from "@tensamin/ui";
|
||||
|
||||
export default function VideoViewer({
|
||||
className,
|
||||
flush = false,
|
||||
publication,
|
||||
participantId,
|
||||
}: {
|
||||
className?: string;
|
||||
flush?: boolean;
|
||||
publication: TrackPublication;
|
||||
participantId: string;
|
||||
}) {
|
||||
const room = useCall((state) => state.room);
|
||||
const tracks = useParticipantTracks([publication.source], {
|
||||
participantIdentity: participantId,
|
||||
room,
|
||||
});
|
||||
const trackRef = tracks[0];
|
||||
|
||||
if (!trackRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoTrack
|
||||
trackRef={trackRef}
|
||||
className={cn(
|
||||
"w-full h-full aspect-video bg-black",
|
||||
flush ? "rounded-none" : "rounded-md",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue