diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6487ba1..a26c462 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -21,6 +21,7 @@ import ChatScreen from "@tensamin/chat/screen"; import CallScreen from "@tensamin/call/screen"; import Login from "@/routes/screens/login"; +import CallPopout from "@tensamin/call/popout"; import ChatContext from "@tensamin/chat/context"; import { useInitializeCall } from "@tensamin/call/store"; import { Provider as TTPProvider } from "@tensamin/ttp"; @@ -147,6 +148,7 @@ function AppShell() { + diff --git a/bun.lock b/bun.lock index 2e3a763..92a897c 100644 --- a/bun.lock +++ b/bun.lock @@ -254,7 +254,7 @@ }, }, "overrides": { - "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz", "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", }, "packages": { @@ -740,7 +740,7 @@ "@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"], - "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-La9VqXqJFtzzsRQotXVp+3Vr6u8kj4mQ4wTlSIMRDxKFBnbCvtZyB3V/f8HiIlf7FlZ0Xg0suUUpnamvtcvs9w=="], + "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-08pYuWTivuDWNiBoSSY2P8rIZz4FvuvLQZMjaQnf7KE/Pz9bGO69XHFcCHQXIz1SWsT2TyjochT0sPEGAta15g=="], "@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-5zql8LtBKVn7WuQDCIdLbthKvLEyxjWShAceXCYbk+kwO41VVILhS1EWuDvjgvv2BBdyuxoVWvdblPIW4BcdSQ=="], diff --git a/package.json b/package.json index 1c2b52d..b89f348 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "overrides": { "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", - "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz" + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz" }, "dependencies": { "@tensamin/ttp-core": "*", diff --git a/packages/call/package.json b/packages/call/package.json index 080dd15..d3b5827 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -7,7 +7,8 @@ "./store": "./src/store.tsx", "./screen": "./src/screen.tsx", "./utils": "./src/utils.ts", - "./sidebarBox": "./src/components/sidebarBox.tsx" + "./sidebarBox": "./src/components/sidebarBox.tsx", + "./popout": "./src/components/popout.tsx" }, "scripts": { "format": "bunx prettier --write .", diff --git a/packages/call/src/components/popout.tsx b/packages/call/src/components/popout.tsx new file mode 100644 index 0000000..1327c9c --- /dev/null +++ b/packages/call/src/components/popout.tsx @@ -0,0 +1,485 @@ +import { Participant, Track } from "livekit-client"; +import VideoViewer from "./videoViewer"; +import { useLocation } from "@tanstack/react-router"; +import { getRoom, stopWatchingStream, useCall } from "../store"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { Button, cn } from "@tensamin/ui"; +import { ScreenShareOff } from "lucide-react"; + +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; + +export function Popout({ participant }: { participant: Participant }) { + const screenSharePublication = getTrackPublicationBySource( + participant, + Track.Source.ScreenShare, + ); + + const popoutRef = useRef(null); + const coordsRef = useRef({ x: MARGIN, y: MARGIN }); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const sizeRef = useRef(400); + const hideOverlayTimeoutRef = useRef | 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("top-left"); + const [coords, setCoords] = useState({ 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; + + // eslint-disable-next-line + 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 = ( + e: React.PointerEvent, + 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( + e, + resizeRef.current.clientX - e.clientX, + (resizeRef.current.clientY - e.clientY) / ASPECT_RATIO, + ); + case "top-right": + return getCornerResizeDelta( + e, + e.clientX - resizeRef.current.clientX, + (resizeRef.current.clientY - e.clientY) / ASPECT_RATIO, + ); + case "bottom-right": + return getCornerResizeDelta( + e, + e.clientX - resizeRef.current.clientX, + (e.clientY - resizeRef.current.clientY) / ASPECT_RATIO, + ); + case "bottom-left": + return getCornerResizeDelta( + e, + resizeRef.current.clientX - e.clientX, + (e.clientY - resizeRef.current.clientY) / ASPECT_RATIO, + ); + } + }; + + const startResize = ( + e: React.PointerEvent, + 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 ( +
{ + 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", + }} + > + +
+
+
+
+
+
+ +
+
+
startResize(e, "top")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "right")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "bottom")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "left")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "top-left")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "top-right")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "bottom-right")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
startResize(e, "bottom-left")} + onPointerMove={resizePopout} + onPointerUp={stopResize} + onPointerCancel={stopResize} + /> +
+ ); +} + +export default function Wrapper() { + const room = getRoom(); + const { pathname } = useLocation(); + const state = useCall((state) => state.state); + const watchedStreamParticipantIds = useCall( + (state) => state.watchedStreamParticipantIds, + ); + const lastFocusedParticipantId = useCall( + (state) => state.lastFocusedParticipantId, + ); + const participant = room.getParticipantByIdentity( + String(lastFocusedParticipantId), + ); + + if (!participant || !lastFocusedParticipantId) { + return null; + } + + const active = + !pathname.startsWith("/call") && + state === "open" && + watchedStreamParticipantIds.includes(Number(participant.identity ?? 0)); + + return active && ; +} diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 7d3e609..efa3802 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -109,6 +109,7 @@ type CallStore = { layoutVersion: number; screenRef: React.RefObject | null; runtime: Runtime | null; + lastFocusedParticipantId: number | null; }; let _keyProvider: ExternalE2EEKeyProvider | null = null; @@ -695,6 +696,7 @@ export function focusParticipant( ) { useCall.setState({ focusedParticipantId: participantId, + lastFocusedParticipantId: participantId, focusedParticipantType: type, view: "focused", }); @@ -855,6 +857,7 @@ export async function disconnect() { watchedStreamParticipantIds: [], pendingWatchedParticipantIds: [], activeScreenShareParticipantIds: [], + lastFocusedParticipantId: null, }); getRoom().remoteParticipants.forEach((participant) => { @@ -1016,6 +1019,7 @@ export function resetCallState() { watchedStreamParticipantIds: [], pendingWatchedParticipantIds: [], activeScreenShareParticipantIds: [], + lastFocusedParticipantId: null, }); syncParticipantState(); @@ -1076,6 +1080,7 @@ export const useCall = create(() => ({ layoutVersion: 0, screenRef: null, runtime: null, + lastFocusedParticipantId: null, })); // Register app-level call listeners and wire React dependencies into the store.