(feat): add live messages

(feat): update licenses
(feat): update livekit logging
(feat): add basic grid layout
(fix): remove deeplink log message
This commit is contained in:
Alois 2026-04-29 23:30:41 +02:00
commit 8e294d230e
21 changed files with 510 additions and 51 deletions

View file

@ -5,14 +5,20 @@ import ScreenshareButton from "./buttons/screenshare";
import LeaveButton from "./buttons/leave";
export default function Actions() {
const sharedClasses = "w-14 h-10";
const sharedIconSize = 15;
return (
<div className="w-full flex justify-center py-3">
<Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75">
<MuteButton className="w-14 h-10" iconSize={25} />
<DeafButton className="w-14 h-10" iconSize={25} />
<ScreenshareButton className="w-14 h-10" iconSize={25} />
<LeaveButton className="w-14 h-10" iconSize={25} />
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
<DeafButton className={sharedClasses} iconSize={sharedIconSize} />
<ScreenshareButton
className={sharedClasses}
iconSize={sharedIconSize}
/>
<LeaveButton className={sharedClasses} iconSize={sharedIconSize} />
</CardContent>
</Card>
</div>

View file

@ -1,6 +1,6 @@
import { LeaveIcon } from "@livekit/components-react";
import { Button } from "@tensamin/ui";
import { disconnect } from "../../store";
import { disconnect, useCall } from "../../store";
export default function LeaveButton({
className,
@ -9,10 +9,13 @@ export default function LeaveButton({
className?: string;
iconSize?: number;
}) {
const state = useCall((store) => store.state);
return (
<Button
className={className}
onClick={() => disconnect()}
disabled={state === "closing" || state === "closed"}
variant="destructive"
>
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />

View file

@ -113,7 +113,7 @@ export function TinyPingGraph() {
<TooltipTrigger
render={
<div
className="w-full h-6"
className="h-6 w-24 min-w-0 shrink-0"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)",
@ -122,13 +122,24 @@ export function TinyPingGraph() {
}}
>
{data.length > 1 && (
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={24}
>
<AreaChart
data={data}
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
>
<defs>
<linearGradient id="pingGradient" x1="0" y1="0" x2="0" y2="1">
<linearGradient
id="pingGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="currentColor"

View file

@ -17,7 +17,6 @@ export default function TopBar() {
let active = true;
if (userIds.length === 0) {
setUsers([]);
return () => {
active = false;
};
@ -42,7 +41,7 @@ export default function TopBar() {
return () => {
active = false;
};
}, [get, userIdsKey]);
}, [get, userIdsKey, userIds]);
return (
<div className="w-full flex justify-between h-12">

View file

@ -15,10 +15,21 @@ import {
RoomEvent,
type RemoteTrack,
Track,
setLogExtension,
getLogger,
} from "livekit-client";
import z from "zod";
import { toast as sonnerToast } from "sonner";
// logging
setLogExtension(
(level, message, context) =>
context
? log(level, "livekit", "blue", message, context)
: log(level, "livekit", "blue", message),
getLogger("tensamin"),
);
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
type CallView = "preview" | "focused" | "grid";
type CurrentCallData =
@ -76,6 +87,7 @@ const e2eeWorker = new Worker(
const room = new Room({
dynacast: true,
adaptiveStream: true,
loggerName: "tensamin",
encryption: {
keyProvider,
worker: e2eeWorker,
@ -83,6 +95,7 @@ const room = new Room({
});
const remoteAudioElements = new Map<string, HTMLMediaElement>();
// audio helpers
function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) {
const existingElement = remoteAudioElements.get(trackSid);
@ -113,6 +126,7 @@ function clearRemoteAudio() {
}
}
// utils
function requireRuntime(runtime: Runtime | null): Runtime {
if (!runtime) {
throw new Error("Call store is not initialized");
@ -132,6 +146,7 @@ export function syncParticipantState() {
});
}
// set state functions
export function setCallRuntime(runtime: Runtime) {
useCall.setState({ runtime });
}
@ -154,6 +169,7 @@ export function setCurrentCallData(
useCall.setState({ currentCallData });
}
// more utils
export async function openCallPage(callId: string) {
await requireRuntime(useCall.getState().runtime).navigate({
to: "/call",
@ -225,6 +241,7 @@ export function disconnect() {
});
room.disconnect();
useCall.setState({ state: "closed" });
syncParticipantState();
}
@ -594,7 +611,7 @@ export function useInitializeCall() {
});
})
.catch((err) => {
log(1, "Call", "red", "Failed to get call data", {
log(1, "call", "red", "Failed to get call data", {
callId,
error: err,
});

View file

@ -1,3 +1,173 @@
import { RoomEvent } from "livekit-client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCall } from "../../store";
const TILE_ASPECT_RATIO = 16 / 9;
const GRID_GAP = 12;
type GridLayout = {
columns: number;
rows: number;
tileWidth: number;
tileHeight: number;
rowCounts: number[];
};
function buildBalancedRows(itemCount: number, rows: number) {
if (itemCount === 0 || rows === 0) {
return [];
}
const baseCount = Math.floor(itemCount / rows);
const remainder = itemCount % rows;
return Array.from(
{ length: rows },
(_, index) => baseCount + (index < remainder ? 1 : 0),
);
}
function calculateOptimalGridLayout(
containerWidth: number,
containerHeight: number,
itemCount: number,
): GridLayout {
if (containerWidth <= 0 || containerHeight <= 0 || itemCount <= 0) {
return {
columns: 0,
rows: 0,
tileWidth: 0,
tileHeight: 0,
rowCounts: [],
};
}
let bestLayout: GridLayout = {
columns: 1,
rows: itemCount,
tileWidth: 0,
tileHeight: 0,
rowCounts: Array.from({ length: itemCount }, () => 1),
};
for (let columns = 1; columns <= itemCount; columns++) {
const rows = Math.ceil(itemCount / columns);
const maxTileWidth =
(containerWidth - GRID_GAP * Math.max(columns - 1, 0)) / columns;
const maxTileHeight =
(containerHeight - GRID_GAP * Math.max(rows - 1, 0)) / rows;
const tileWidth = Math.min(maxTileWidth, maxTileHeight * TILE_ASPECT_RATIO);
const tileHeight = tileWidth / TILE_ASPECT_RATIO;
if (tileWidth <= 0 || tileHeight <= 0) {
continue;
}
const currentArea = tileWidth * tileHeight;
const bestArea = bestLayout.tileWidth * bestLayout.tileHeight;
if (currentArea > bestArea) {
bestLayout = {
columns,
rows,
tileWidth,
tileHeight,
rowCounts: buildBalancedRows(itemCount, rows),
};
}
}
return bestLayout;
}
export default function View() {
return <div className="w-full h-full bg-red-500">Grid</div>;
const room = useCall((state) => state.room);
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [participantCount, setParticipantCount] = useState(
() => room.remoteParticipants.size + 1,
);
useEffect(() => {
const element = containerRef.current;
if (!element) {
return;
}
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setContainerSize({ width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const syncParticipantCount = () => {
setParticipantCount(room.remoteParticipants.size + 1);
};
syncParticipantCount();
room.on(RoomEvent.Connected, syncParticipantCount);
room.on(RoomEvent.Disconnected, syncParticipantCount);
room.on(RoomEvent.ParticipantConnected, syncParticipantCount);
room.on(RoomEvent.ParticipantDisconnected, syncParticipantCount);
return () => {
room.off(RoomEvent.Connected, syncParticipantCount);
room.off(RoomEvent.Disconnected, syncParticipantCount);
room.off(RoomEvent.ParticipantConnected, syncParticipantCount);
room.off(RoomEvent.ParticipantDisconnected, syncParticipantCount);
};
}, [room]);
const layout = useMemo(
() =>
calculateOptimalGridLayout(
containerSize.width,
containerSize.height,
participantCount,
),
[containerSize.height, containerSize.width, participantCount],
);
const rows = useMemo(() => {
let nextParticipant = 1;
return layout.rowCounts.map((count, rowIndex) => {
const participants = Array.from(
{ length: count },
() => nextParticipant++,
);
return { rowIndex, participants };
});
}, [layout.rowCounts]);
return (
<div ref={containerRef} className="h-full 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((participant) => (
<div
key={participant}
className="flex items-center justify-center rounded-xl bg-red-500"
style={{
width: layout.tileWidth,
height: layout.tileHeight,
}}
>
Test {participant}
</div>
))}
</div>
))}
</div>
</div>
);
}

View file

@ -12,7 +12,6 @@ export default function Preview() {
let active = true;
if (!currentCallData?.exists) {
setData([]);
return () => {
active = false;
};
@ -52,7 +51,7 @@ export default function Preview() {
})}
</div>
) : (
<p className="text-2xl">Invalid call</p>
<p className="text-2xl">Call expired</p>
)}
</div>
);