(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,3 +1,147 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCall } from "../../store";
import Base from "../../components/modals/base";
const TILE_ASPECT_RATIO = 16 / 9;
const SECONDARY_ROW_HEIGHT_PX = 180;
const STACK_GAP_PX = 12;
export default function View() {
return <div>Focused</div>;
const room = useCall((state) => state.room);
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
const activeScreenShareParticipantIds = useCall(
(state) => state.activeScreenShareParticipantIds,
);
const containerRef = useRef<HTMLDivElement | null>(null);
const [focusedTileSize, setFocusedTileSize] = useState({ width: 0, height: 0 });
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
const users = useMemo(() => {
const participants = [
...room.remoteParticipants.values(),
room.localParticipant,
];
return participants.filter((participant) => {
const participantId = Number(participant.identity);
return Number.isInteger(participantId) && participantId > 0;
});
}, [room]);
const userIds = useMemo(
() => users.map((participant) => Number(participant.identity)),
[users],
);
const tiles = useMemo(
() => [
...activeScreenShareParticipantIds
.filter((id) => id !== focusedParticipantId)
.map((participantId) => ({
key: `stream:${participantId}`,
kind: "stream" as const,
participantId,
})),
...userIds
.filter((id) => id !== focusedParticipantId)
.filter((id) => !activeScreenShareParticipantIds.includes(id))
.map((participantId) => ({
key: `user:${participantId}`,
kind: "user" as const,
participantId,
})),
],
[activeScreenShareParticipantIds, userIds, focusedParticipantId],
);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const syncTileLayout = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(0, Math.floor(window.innerWidth + 1 - rect.left));
const height = Math.max(0, Math.floor(container.clientHeight));
const reservedHeight =
tiles.length > 0 ? SECONDARY_ROW_HEIGHT_PX + STACK_GAP_PX : 0;
const availableHeight = Math.max(0, height - reservedHeight);
const nextWidth = Math.max(
0,
Math.min(width, availableHeight * TILE_ASPECT_RATIO),
);
const nextHeight = Math.max(0, nextWidth / TILE_ASPECT_RATIO);
const widthDelta = Math.abs(width - nextWidth);
setFocusedTileSize((current) =>
current.width === nextWidth && current.height === nextHeight
? current
: { width: nextWidth, height: nextHeight },
);
setIsFocusedTileFlush(widthDelta <= 1);
};
syncTileLayout();
const observer = new ResizeObserver(() => {
requestAnimationFrame(syncTileLayout);
});
observer.observe(container);
window.addEventListener("resize", syncTileLayout);
return () => {
observer.disconnect();
window.removeEventListener("resize", syncTileLayout);
};
}, [tiles.length, focusedParticipantId]);
if (focusedParticipantId == null) {
return null;
}
const focusedParticipant = room.getParticipantByIdentity(
String(focusedParticipantId),
);
return (
<div ref={containerRef} className="flex h-full w-full items-center justify-center overflow-hidden">
<div
className="flex max-h-full w-full flex-col items-center overflow-hidden"
style={{ gap: tiles.length > 0 ? STACK_GAP_PX : 0 }}
>
<div className="w-full flex justify-center overflow-hidden">
<div className="overflow-hidden" style={focusedTileSize}>
<Base
flush={isFocusedTileFlush}
type={focusedParticipant?.isScreenShareEnabled ? "stream" : "user"}
participant={room.getParticipantByIdentity(
String(focusedParticipantId),
)}
/>
</div>
</div>
{tiles.length > 0 && (
<div
className="w-full shrink-0 flex justify-center gap-2 overflow-x-auto"
style={{ height: SECONDARY_ROW_HEIGHT_PX }}
>
{tiles.map((tile) => (
<div key={tile.key} className="h-full shrink-0 aspect-video">
<Base
type={tile.kind}
participant={room.getParticipantByIdentity(
String(tile.participantId),
)}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { RoomEvent } from "livekit-client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCall } from "../../store";
import Base from "../../components/modals/base";
const TILE_ASPECT_RATIO = 16 / 9;
const GRID_GAP = 12;
@ -82,12 +83,13 @@ function calculateOptimalGridLayout(
export default function View() {
const room = useCall((state) => state.room);
const activeScreenShareParticipantIds = useCall(
(state) => state.activeScreenShareParticipantIds,
);
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [participantCount, setParticipantCount] = useState(
() => room.remoteParticipants.size + 1,
);
const [participantVersion, setParticipantVersion] = useState(0);
useEffect(() => {
const element = containerRef.current;
@ -107,33 +109,53 @@ export default function View() {
}, []);
useEffect(() => {
const syncParticipantCount = () => {
setParticipantCount(room.remoteParticipants.size + 1);
const syncParticipants = () => {
setParticipantVersion((version) => version + 1);
};
syncParticipantCount();
syncParticipants();
room.on(RoomEvent.Connected, syncParticipantCount);
room.on(RoomEvent.Disconnected, syncParticipantCount);
room.on(RoomEvent.ParticipantConnected, syncParticipantCount);
room.on(RoomEvent.ParticipantDisconnected, syncParticipantCount);
room.on(RoomEvent.Connected, syncParticipants);
room.on(RoomEvent.Disconnected, syncParticipants);
room.on(RoomEvent.ParticipantConnected, syncParticipants);
room.on(RoomEvent.ParticipantDisconnected, syncParticipants);
return () => {
room.off(RoomEvent.Connected, syncParticipantCount);
room.off(RoomEvent.Disconnected, syncParticipantCount);
room.off(RoomEvent.ParticipantConnected, syncParticipantCount);
room.off(RoomEvent.ParticipantDisconnected, syncParticipantCount);
room.off(RoomEvent.Connected, syncParticipants);
room.off(RoomEvent.Disconnected, syncParticipants);
room.off(RoomEvent.ParticipantConnected, syncParticipants);
room.off(RoomEvent.ParticipantDisconnected, syncParticipants);
};
}, [room]);
const users = useMemo(() => {
const participants = [...room.remoteParticipants.values(), room.localParticipant];
return participants.filter((participant) => {
const participantId = Number(participant.identity);
return Number.isInteger(participantId) && participantId > 0;
});
// eslint-disable-next-line
}, [participantVersion, room]);
const userIds = useMemo(
() => users.map((participant) => Number(participant.identity)),
[users],
);
const layout = useMemo(
() =>
calculateOptimalGridLayout(
containerSize.width,
containerSize.height,
participantCount,
activeScreenShareParticipantIds.length + userIds.length,
),
[containerSize.height, containerSize.width, participantCount],
[
activeScreenShareParticipantIds.length,
containerSize.height,
containerSize.width,
userIds.length,
],
);
const rows = useMemo(() => {
@ -148,31 +170,57 @@ export default function View() {
});
}, [layout.rowCounts]);
const users = useMemo(() => {
const participants = [
...room.remoteParticipants.values(),
room.localParticipant,
];
return participants;
}, [room.localParticipant, room.remoteParticipants]);
const tiles = useMemo(
() => [
...activeScreenShareParticipantIds.map((participantId) => ({
key: `stream:${participantId}`,
kind: "stream" as const,
participantId,
})),
...userIds.map((participantId) => ({
key: `user:${participantId}`,
kind: "user" as const,
participantId,
})),
],
[activeScreenShareParticipantIds, userIds],
);
function getParticipantById(participantId: number) {
if (Number(room.localParticipant.identity) === participantId) {
return room.localParticipant;
}
return room.getParticipantByIdentity(String(participantId));
}
return (
<div ref={containerRef} className="h-[75vh] w-full overflow-hidden p-3">
<div ref={containerRef} style={{
height: "calc(100% - 2rem)"
}} className="w-full overflow-hidden p-3">
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
{rows.map((row) => (
<div key={row.rowIndex} className="flex justify-center gap-3">
{row.participants.map((userIndex) => (
<div
key={userIndex}
className="flex items-center justify-center rounded-xl bg-red-500"
style={{
width: layout.tileWidth,
height: layout.tileHeight,
}}
>
Test {users[userIndex]?.identity}
</div>
))}
{row.participants.map((userIndex) => {
const tile = tiles[userIndex];
if (!tile) {
return null;
}
return (
<div
key={tile.key}
className="flex items-center justify-center rounded-xl"
style={{
width: layout.tileWidth,
height: layout.tileHeight,
}}
>
<Base type={tile.kind} participant={getParticipantById(tile.participantId)} />
</div>
);
})}
</div>
))}
</div>

View file

@ -4,9 +4,15 @@ import TopBar from "../../components/top";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="w-full h-full flex flex-col">
<TopBar />
<div className="w-full h-full">{children}</div>
<Actions />
<div className="shrink-0">
<TopBar />
</div>
<div className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden">
{children}
</div>
<div className="shrink-0 pb-4.5 pt-3">
<Actions />
</div>
</div>
);
}