(feat): add basic call ui

(feat): add screensharing (incl. broken desktop picker)
(qol): add todo
This commit is contained in:
Alois 2026-04-30 22:41:49 +02:00
commit fbd8ef4fa0
21 changed files with 1167 additions and 380 deletions

View file

@ -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>

View 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>
);
}

View file

@ -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}

View 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>
);
}

View file

@ -1,3 +0,0 @@
export default function SmallUser() {
return <div>Small User</div>;
}

View file

@ -1,3 +0,0 @@
export default function SmallVideo() {
return <div>Small Video</div>;
}

View file

@ -1,3 +0,0 @@
export default function BigUser() {
return <div>Big User</div>;
}

View file

@ -1,3 +0,0 @@
export default function BigVideo() {
return <div>Big Video</div>;
}

View 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>
)}

View file

@ -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>
);
}

View 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,
)}
/>
);
}