client/packages/call/src/components/popout.tsx
Alois 62aaa7c01d
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(feat): improve mobile
(feat): add camera sharing
(fix): vite tauri connection
(fix): methanium/ui theme not applied to call popout
2026-08-01 01:25:41 +02:00

736 lines
21 KiB
TypeScript

import { Participant, Track } from "livekit-client";
import VideoViewer from "./videoViewer";
import { useLocation } from "@tanstack/react-router";
import { getRoom, openCallPage, stopWatchingStream, useCall } from "../store";
import { useState, useRef, useEffect, useCallback } from "react";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Card,
cn,
useIsMobile,
useSidebar,
} from "@methanium/ui";
import { ScreenShareOff } from "lucide-react";
import { useUser, type User } from "@tensamin/user/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base";
function getTrackPublicationBySource(
participant: Participant | undefined,
source: Track.Source,
) {
if (!participant) {
return undefined;
}
return [...participant.trackPublications.values()].find(
(publication) => publication.source === source,
);
}
type Positions = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type ResizeEdge =
| "top"
| "right"
| "bottom"
| "left"
| "top-left"
| "top-right"
| "bottom-right"
| "bottom-left";
type Point = {
x: number;
y: number;
};
const MARGIN = 40;
const MIN_SIZE = 240;
const MAX_SIZE = 1000;
const ASPECT_RATIO = 9 / 16;
const MOBILE_MARGIN = 16;
const MOBILE_PILL_WIDTH = 128;
const MOBILE_PILL_HEIGHT = 48;
function MobileCallPill({
active,
callId,
}: {
active: boolean;
callId: string;
}) {
const { setOpenMobile } = useSidebar();
const { get } = useUser();
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined
>(undefined);
const safeAreaRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const initialCoords = {
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
y: MOBILE_MARGIN,
};
const coordsRef = useRef<Point>(initialCoords);
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
const dragStartRef = useRef<Point>({ x: 0, y: 0 });
const movedRef = useRef(false);
const [position, setPosition] = useState<Positions>("top-right");
const [coords, setCoords] = useState(initialCoords);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (lastSpeakingParticipantId == null) {
setLastSpeakingUser(null);
return;
}
let mounted = true;
void get(lastSpeakingParticipantId).then((user) => {
if (mounted) {
setLastSpeakingUser(user);
}
});
return () => {
mounted = false;
};
}, [get, lastSpeakingParticipantId]);
useEffect(() => {
if (!lastSpeakingUser?.Avatar) {
setAvatarBackgroundColor(undefined);
return;
}
let mounted = true;
void getAverageImageColor(lastSpeakingUser.Avatar).then((color) => {
if (mounted) {
setAvatarBackgroundColor(color);
}
});
return () => {
mounted = false;
};
}, [lastSpeakingUser?.Avatar]);
const getSafeArea = useCallback(() => {
const element = safeAreaRef.current;
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
const style = getComputedStyle(element);
return {
top: parseFloat(style.paddingTop) || 0,
right: parseFloat(style.paddingRight) || 0,
bottom: parseFloat(style.paddingBottom) || 0,
left: parseFloat(style.paddingLeft) || 0,
};
}, []);
const getCoordsForPosition = useCallback(
(nextPosition: Positions): Point => {
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
return {
x: nextPosition.endsWith("right")
? window.innerWidth - safeArea.right - width - MOBILE_MARGIN
: safeArea.left + MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN
: safeArea.top + MOBILE_MARGIN,
};
},
[getSafeArea],
);
const setCoordsSafe = useCallback((next: Point) => {
coordsRef.current = next;
setCoords(next);
}, []);
const snapToPosition = useCallback(
(nextPosition: Positions) => {
setPosition(nextPosition);
setCoordsSafe(getCoordsForPosition(nextPosition));
},
[getCoordsForPosition, setCoordsSafe],
);
useEffect(() => {
if (active && !isDragging) {
snapToPosition(position);
}
}, [active, isDragging, position, snapToPosition]);
useEffect(() => {
const handleResize = () => snapToPosition(position);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [position, snapToPosition]);
if (!active) {
return null;
}
return (
<div
ref={safeAreaRef}
className="pointer-events-none fixed inset-0 z-200 pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
>
<Card
ref={pillRef}
onClick={() => {
if (movedRef.current) {
movedRef.current = false;
return;
}
setOpenMobile(false);
void openCallPage(callId);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - coordsRef.current.x,
y: event.clientY - coordsRef.current.y,
};
dragStartRef.current = { x: event.clientX, y: event.clientY };
movedRef.current = false;
setIsDragging(true);
}}
onPointerMove={(event) => {
if (!isDragging) return;
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
const next = {
x: Math.min(
window.innerWidth - safeArea.right - width - MOBILE_MARGIN,
Math.max(
safeArea.left + MOBILE_MARGIN,
event.clientX - dragOffsetRef.current.x,
),
),
y: Math.min(
window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN,
Math.max(
safeArea.top + MOBILE_MARGIN,
event.clientY - dragOffsetRef.current.y,
),
),
};
if (
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
Math.abs(event.clientY - dragStartRef.current.y) > 3
) {
movedRef.current = true;
}
setCoordsSafe(next);
}}
onPointerUp={(event) => {
if (!isDragging) return;
const nextPosition = `${
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
setIsDragging(false);
snapToPosition(nextPosition);
}}
onPointerCancel={() => {
setIsDragging(false);
snapToPosition(position);
}}
className={cn(
"pointer-events-auto fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundColor: avatarBackgroundColor,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition: isDragging
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
</div>
);
}
export function Popout({ participant }: { participant: Participant }) {
const screenSharePublication = getTrackPublicationBySource(
participant,
Track.Source.ScreenShare,
);
const popoutRef = useRef<HTMLDivElement>(null);
const coordsRef = useRef<Point>({ x: MARGIN, y: MARGIN });
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
const sizeRef = useRef(400);
const hideOverlayTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const resizeRef = useRef<{
size: number;
clientX: number;
clientY: number;
edge: ResizeEdge;
}>({ size: 400, clientX: 0, clientY: 0, edge: "right" });
const [size, setSize] = useState(400);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [isOverlayVisible, setIsOverlayVisible] = useState(false);
const [position, setPosition] = useState<Positions>("top-left");
const [coords, setCoords] = useState<Point>({ x: MARGIN, y: MARGIN });
const setCoordsSafe = (next: Point) => {
coordsRef.current = next;
setCoords(next);
};
const setSizeSafe = (next: number) => {
sizeRef.current = next;
setSize(next);
};
const getMaxSize = useCallback(() => {
const maxWidth = window.innerWidth - MARGIN * 2;
const maxHeightWidth = (window.innerHeight - MARGIN * 2) / ASPECT_RATIO;
return Math.max(MIN_SIZE, Math.min(MAX_SIZE, maxWidth, maxHeightWidth));
}, []);
const clampSize = useCallback(
(nextSize: number) => {
return Math.min(getMaxSize(), Math.max(MIN_SIZE, nextSize));
},
[getMaxSize],
);
const getPositionFromPoint = (
clientX: number,
clientY: number,
): Positions => {
const height = window.innerHeight / 2 > clientY ? "top" : "bottom";
const width = window.innerWidth / 2 > clientX ? "left" : "right";
return `${height}-${width}` as Positions;
};
const getCoordsForPosition = useCallback(
(nextPosition: Positions, nextSize = size): Point => {
const width = nextSize;
const height = nextSize * ASPECT_RATIO;
return {
x: nextPosition.endsWith("right")
? window.innerWidth - width - MARGIN
: MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - height - MARGIN
: MARGIN,
};
},
[size],
);
useEffect(() => {
if (isDragging) return;
setCoordsSafe(getCoordsForPosition(position));
}, [position, size, isDragging, getCoordsForPosition]);
useEffect(() => {
const handleResize = () => {
if (isDragging) return;
const nextSize = clampSize(size);
setSizeSafe(nextSize);
setCoordsSafe(getCoordsForPosition(position, nextSize));
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [position, size, isDragging, getCoordsForPosition, clampSize]);
useEffect(() => {
return () => {
if (hideOverlayTimeoutRef.current) {
clearTimeout(hideOverlayTimeoutRef.current);
}
};
}, []);
const scheduleOverlayHide = () => {
if (hideOverlayTimeoutRef.current) {
clearTimeout(hideOverlayTimeoutRef.current);
}
hideOverlayTimeoutRef.current = setTimeout(() => {
setIsOverlayVisible(false);
hideOverlayTimeoutRef.current = null;
}, 3000);
};
const showOverlay = () => {
setIsOverlayVisible(true);
scheduleOverlayHide();
};
const hideOverlay = () => {
if (hideOverlayTimeoutRef.current) {
clearTimeout(hideOverlayTimeoutRef.current);
hideOverlayTimeoutRef.current = null;
}
setIsOverlayVisible(false);
};
const getCornerResizeDelta = (
horizontalDelta: number,
verticalDelta: number,
) => {
return Math.abs(horizontalDelta) > Math.abs(verticalDelta)
? horizontalDelta
: verticalDelta;
};
const getResizeDelta = (e: React.PointerEvent, edge: ResizeEdge) => {
switch (edge) {
case "left":
return resizeRef.current.clientX - e.clientX;
case "right":
return e.clientX - resizeRef.current.clientX;
case "top":
return (resizeRef.current.clientY - e.clientY) / ASPECT_RATIO;
case "bottom":
return (e.clientY - resizeRef.current.clientY) / ASPECT_RATIO;
case "top-left":
return getCornerResizeDelta(
resizeRef.current.clientX - e.clientX,
(resizeRef.current.clientY - e.clientY) / ASPECT_RATIO,
);
case "top-right":
return getCornerResizeDelta(
e.clientX - resizeRef.current.clientX,
(resizeRef.current.clientY - e.clientY) / ASPECT_RATIO,
);
case "bottom-right":
return getCornerResizeDelta(
e.clientX - resizeRef.current.clientX,
(e.clientY - resizeRef.current.clientY) / ASPECT_RATIO,
);
case "bottom-left":
return getCornerResizeDelta(
resizeRef.current.clientX - e.clientX,
(e.clientY - resizeRef.current.clientY) / ASPECT_RATIO,
);
}
};
const startResize = (
e: React.PointerEvent<HTMLDivElement>,
edge: ResizeEdge,
) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
resizeRef.current = {
size,
clientX: e.clientX,
clientY: e.clientY,
edge,
};
setIsResizing(true);
};
const resizePopout = (e: React.PointerEvent) => {
if (!isResizing) return;
const nextSize = clampSize(
resizeRef.current.size + getResizeDelta(e, resizeRef.current.edge),
);
setSizeSafe(nextSize);
setCoordsSafe(getCoordsForPosition(position, nextSize));
};
const stopResize = (e: React.PointerEvent) => {
if (!isResizing) return;
e.stopPropagation();
setIsResizing(false);
setCoordsSafe(getCoordsForPosition(position, sizeRef.current));
};
const resizeEdgeClassName = "absolute z-10 bg-transparent";
const resizeCornerClassName = "absolute z-20 h-4 w-4 bg-transparent";
if (!screenSharePublication) {
return null;
}
return (
<div
ref={popoutRef}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (isResizing) return;
e.currentTarget.setPointerCapture(e.pointerId);
const current = coordsRef.current;
dragOffsetRef.current = {
x: e.clientX - current.x,
y: e.clientY - current.y,
};
setIsDragging(true);
}}
onPointerMove={(e) => {
if (!isDragging) return;
setCoordsSafe({
x: e.clientX - dragOffsetRef.current.x,
y: e.clientY - dragOffsetRef.current.y,
});
}}
onPointerUp={(e) => {
if (!isDragging) return;
const nextPosition = getPositionFromPoint(e.clientX, e.clientY);
setPosition(nextPosition);
setIsDragging(false);
requestAnimationFrame(() => {
setCoordsSafe(getCoordsForPosition(nextPosition));
});
}}
onPointerCancel={() => {
setIsDragging(false);
requestAnimationFrame(() => {
setCoordsSafe(getCoordsForPosition(position));
});
}}
onMouseEnter={showOverlay}
onMouseMove={showOverlay}
onMouseLeave={hideOverlay}
className={cn(
"fixed left-0 top-0 aspect-video z-200 rounded-lg border-2 border-muted-foreground bg-black",
"select-none touch-none",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
width: size,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition:
isDragging || isResizing
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
>
<VideoViewer
participantId={participant.identity}
publication={screenSharePublication}
/>
<div
className={cn(
"pointer-events-none absolute inset-0 z-40 transition-opacity duration-200",
isOverlayVisible ? "opacity-100" : "opacity-0",
)}
>
<div className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/55 to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/65 to-transparent" />
</div>
<div
className={cn(
"absolute bottom-3 right-3 z-50 transition-all duration-200",
isOverlayVisible
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 translate-y-2 pointer-events-none",
)}
>
<div className="bg-[#070707] h-10 w-14 rounded-lg">
<Button
className="w-full h-full border-0!"
variant="destructive"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
stopWatchingStream(Number(participant.identity ?? 0));
}}
>
<ScreenShareOff style={{ scale: "115%" }} />
</Button>
</div>
</div>
<div
aria-label="Resize popout from top edge"
role="separator"
className={cn(
resizeEdgeClassName,
"inset-x-4 -top-1 h-2 cursor-n-resize",
)}
onPointerDown={(e) => startResize(e, "top")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from right edge"
role="separator"
className={cn(
resizeEdgeClassName,
"-right-1 inset-y-4 w-2 cursor-e-resize",
)}
onPointerDown={(e) => startResize(e, "right")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from bottom edge"
role="separator"
className={cn(
resizeEdgeClassName,
"inset-x-4 -bottom-1 h-2 cursor-s-resize",
)}
onPointerDown={(e) => startResize(e, "bottom")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from left edge"
role="separator"
className={cn(
resizeEdgeClassName,
"-left-1 inset-y-4 w-2 cursor-w-resize",
)}
onPointerDown={(e) => startResize(e, "left")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from top left corner"
role="separator"
className={cn(resizeCornerClassName, "-left-1 -top-1 cursor-nw-resize")}
onPointerDown={(e) => startResize(e, "top-left")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from top right corner"
role="separator"
className={cn(
resizeCornerClassName,
"-right-1 -top-1 cursor-ne-resize",
)}
onPointerDown={(e) => startResize(e, "top-right")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from bottom right corner"
role="separator"
className={cn(
resizeCornerClassName,
"-bottom-1 -right-1 cursor-se-resize",
)}
onPointerDown={(e) => startResize(e, "bottom-right")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
<div
aria-label="Resize popout from bottom left corner"
role="separator"
className={cn(
resizeCornerClassName,
"-bottom-1 -left-1 cursor-sw-resize",
)}
onPointerDown={(e) => startResize(e, "bottom-left")}
onPointerMove={resizePopout}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
</div>
);
}
export default function Wrapper() {
const room = getRoom();
const { pathname } = useLocation();
const { openMobile } = useSidebar();
const isMobile = useIsMobile();
const state = useCall((state) => state.state);
const callId = useCall((state) => state.callId);
const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds,
);
const lastFocusedParticipantId = useCall(
(state) => state.lastFocusedParticipantId,
);
const participant = room.getParticipantByIdentity(
String(lastFocusedParticipantId),
);
if (isMobile) {
return (
<MobileCallPill
active={
(!pathname.startsWith("/call") || openMobile) && state === "open"
}
callId={callId ?? ""}
/>
);
}
if (!participant || !lastFocusedParticipantId) {
return null;
}
const active =
!pathname.startsWith("/call") &&
state === "open" &&
watchedStreamParticipantIds.includes(Number(participant.identity ?? 0));
return active && <Popout participant={participant} />;
}