2026-05-14 22:50:20 -04:00
|
|
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
2026-03-07 18:03:32 +11:00
|
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
2026-03-09 14:04:48 +11:00
|
|
|
import { config } from 'folds';
|
2026-03-07 18:03:32 +11:00
|
|
|
import {
|
|
|
|
|
CallEmbedContextProvider,
|
|
|
|
|
CallEmbedRefContextProvider,
|
|
|
|
|
useCallHangupEvent,
|
|
|
|
|
useCallJoined,
|
|
|
|
|
useCallThemeSync,
|
2026-03-08 14:22:11 +11:00
|
|
|
useCallMemberSoundSync,
|
2026-03-07 18:03:32 +11:00
|
|
|
} from '../hooks/useCallEmbed';
|
|
|
|
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
|
|
|
|
import { CallEmbed } from '../plugins/call';
|
|
|
|
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
|
|
|
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
2026-05-14 22:50:20 -04:00
|
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
const PIP_MIN_W = 200;
|
|
|
|
|
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
|
|
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
|
|
|
|
2026-03-08 14:22:11 +11:00
|
|
|
useCallMemberSoundSync(embed);
|
2026-03-07 18:03:32 +11:00
|
|
|
useCallThemeSync(embed);
|
|
|
|
|
useCallHangupEvent(
|
|
|
|
|
embed,
|
|
|
|
|
useCallback(() => {
|
|
|
|
|
setCallEmbed(undefined);
|
|
|
|
|
}, [setCallEmbed])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
|
|
|
|
|
|
|
|
|
/** Normalise the element to top/left positioning so resize math is uniform. */
|
|
|
|
|
function normaliseToTopLeft(el: HTMLDivElement) {
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
el.style.top = `${rect.top}px`;
|
|
|
|
|
el.style.left = `${rect.left}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
|
|
|
|
el.style.width = `${rect.width}px`;
|
|
|
|
|
el.style.height = `${rect.height}px`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
type CallEmbedProviderProps = {
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
};
|
|
|
|
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|
|
|
|
const callEmbed = useAtomValue(callEmbedAtom);
|
|
|
|
|
const callEmbedRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const joined = useCallJoined(callEmbed);
|
|
|
|
|
|
|
|
|
|
const selectedRoom = useSelectedRoom();
|
|
|
|
|
const chat = useAtomValue(callChatAtom);
|
|
|
|
|
const screenSize = useScreenSizeContext();
|
2026-05-14 22:50:20 -04:00
|
|
|
const { navigateRoom } = useRoomNavigate();
|
2026-03-07 18:03:32 +11:00
|
|
|
|
|
|
|
|
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
2026-05-14 22:50:20 -04:00
|
|
|
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
|
|
|
|
const callActive = callEmbed && joined;
|
|
|
|
|
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
|
|
|
|
const pipMode = callActive && !inCallRoom;
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-05-14 22:50:20 -04:00
|
|
|
const pipDragRef = useRef<{
|
|
|
|
|
startX: number;
|
|
|
|
|
startY: number;
|
|
|
|
|
origLeft: number;
|
|
|
|
|
origTop: number;
|
|
|
|
|
dragged: boolean;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
|
|
|
|
|
// Override those in PiP mode; clear them when returning so it can take back control.
|
2026-05-14 22:50:20 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (pipMode) {
|
|
|
|
|
el.style.top = 'auto';
|
|
|
|
|
el.style.left = 'auto';
|
|
|
|
|
el.style.bottom = '72px';
|
|
|
|
|
el.style.right = '16px';
|
|
|
|
|
el.style.width = '280px';
|
|
|
|
|
el.style.height = '158px';
|
|
|
|
|
el.style.borderRadius = '12px';
|
|
|
|
|
el.style.overflow = 'hidden';
|
|
|
|
|
el.style.zIndex = '99';
|
|
|
|
|
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
|
|
|
|
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
|
|
|
|
el.style.visibility = 'visible';
|
|
|
|
|
} else {
|
|
|
|
|
el.style.top = '';
|
|
|
|
|
el.style.left = '';
|
|
|
|
|
el.style.bottom = '';
|
|
|
|
|
el.style.right = '';
|
2026-05-14 23:07:29 -04:00
|
|
|
el.style.width = '';
|
|
|
|
|
el.style.height = '';
|
2026-05-14 22:50:20 -04:00
|
|
|
el.style.borderRadius = '';
|
|
|
|
|
el.style.overflow = '';
|
|
|
|
|
el.style.zIndex = '';
|
|
|
|
|
el.style.boxShadow = '';
|
|
|
|
|
el.style.border = '';
|
|
|
|
|
el.style.visibility = callVisible ? '' : 'hidden';
|
|
|
|
|
}
|
|
|
|
|
}, [pipMode, callVisible]);
|
|
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
// ── Drag to move ────────────────────────────────────────────────────────────
|
2026-05-14 22:50:20 -04:00
|
|
|
const handlePipMouseDown = (e: React.MouseEvent) => {
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
pipDragRef.current = {
|
|
|
|
|
startX: e.clientX,
|
|
|
|
|
startY: e.clientY,
|
|
|
|
|
origLeft: rect.left,
|
|
|
|
|
origTop: rect.top,
|
|
|
|
|
dragged: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseMove = (ev: MouseEvent) => {
|
|
|
|
|
if (!pipDragRef.current || !el) return;
|
|
|
|
|
const dx = ev.clientX - pipDragRef.current.startX;
|
|
|
|
|
const dy = ev.clientY - pipDragRef.current.startY;
|
|
|
|
|
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
|
|
|
|
pipDragRef.current.dragged = true;
|
|
|
|
|
document.body.style.cursor = 'grabbing';
|
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
}
|
|
|
|
|
if (pipDragRef.current.dragged) {
|
|
|
|
|
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
|
|
|
|
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
|
|
|
|
el.style.left = `${newLeft}px`;
|
|
|
|
|
el.style.top = `${newTop}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseUp = () => {
|
|
|
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePipTouchStart = (e: React.TouchEvent) => {
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el || e.touches.length !== 1) return;
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
pipDragRef.current = {
|
|
|
|
|
startX: touch.clientX,
|
|
|
|
|
startY: touch.clientY,
|
|
|
|
|
origLeft: rect.left,
|
|
|
|
|
origTop: rect.top,
|
|
|
|
|
dragged: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onTouchMove = (ev: TouchEvent) => {
|
|
|
|
|
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
const t = ev.touches[0];
|
|
|
|
|
const dx = t.clientX - pipDragRef.current.startX;
|
|
|
|
|
const dy = t.clientY - pipDragRef.current.startY;
|
|
|
|
|
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
|
|
|
|
pipDragRef.current.dragged = true;
|
|
|
|
|
}
|
|
|
|
|
if (pipDragRef.current.dragged) {
|
|
|
|
|
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
|
|
|
|
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
|
|
|
|
el.style.left = `${newLeft}px`;
|
|
|
|
|
el.style.top = `${newTop}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onTouchEnd = () => {
|
|
|
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
|
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
|
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePipClick = (roomId: string) => {
|
|
|
|
|
if (pipDragRef.current?.dragged) return;
|
|
|
|
|
navigateRoom(roomId);
|
|
|
|
|
};
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
// ── Resize from corner handles ───────────────────────────────────────────────
|
|
|
|
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
normaliseToTopLeft(el);
|
|
|
|
|
|
|
|
|
|
const startX = e.clientX;
|
|
|
|
|
const startY = e.clientY;
|
|
|
|
|
const startW = el.offsetWidth;
|
|
|
|
|
const startH = el.offsetHeight;
|
|
|
|
|
const startL = parseFloat(el.style.left);
|
|
|
|
|
const startT = parseFloat(el.style.top);
|
|
|
|
|
|
|
|
|
|
document.body.style.cursor = `${corner}-resize`;
|
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
|
|
|
|
|
const onMouseMove = (ev: MouseEvent) => {
|
|
|
|
|
const dx = ev.clientX - startX;
|
|
|
|
|
const dy = ev.clientY - startY;
|
|
|
|
|
|
|
|
|
|
let w = startW;
|
|
|
|
|
let h = startH;
|
|
|
|
|
let l = startL;
|
|
|
|
|
let t = startT;
|
|
|
|
|
|
|
|
|
|
if (corner === 'se') { w = startW + dx; h = startH + dy; }
|
|
|
|
|
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
|
|
|
|
|
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
|
|
|
|
|
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
|
|
|
|
|
|
|
|
|
|
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
|
|
|
|
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
|
|
|
|
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
|
|
|
|
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
|
|
|
|
|
|
|
|
|
el.style.width = `${w}px`;
|
|
|
|
|
el.style.height = `${h}px`;
|
|
|
|
|
el.style.left = `${l}px`;
|
|
|
|
|
el.style.top = `${t}px`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseUp = () => {
|
|
|
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Corner handle style helper
|
|
|
|
|
const cornerHandle = (corner: Corner): React.CSSProperties => {
|
|
|
|
|
const s = corner.includes('s');
|
|
|
|
|
const e = corner.includes('e');
|
|
|
|
|
return {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
width: '18px',
|
|
|
|
|
height: '18px',
|
|
|
|
|
[s ? 'bottom' : 'top']: 0,
|
|
|
|
|
[e ? 'right' : 'left']: 0,
|
|
|
|
|
cursor: `${corner}-resize`,
|
|
|
|
|
zIndex: 2,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Grip dot pattern rendered inside each handle
|
|
|
|
|
const gripDots = (corner: Corner) => {
|
|
|
|
|
const s = corner.includes('s');
|
|
|
|
|
const e = corner.includes('e');
|
|
|
|
|
// 3 dots arranged in an L toward the active corner
|
|
|
|
|
const dots: React.CSSProperties[] = [];
|
|
|
|
|
const r = 2; // dot radius px
|
|
|
|
|
const gap = 5;
|
|
|
|
|
const base = 3;
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
for (let j = 0; j < 3; j++) {
|
|
|
|
|
if (i + j < 2) continue; // only the 3 corner-most dots
|
|
|
|
|
dots.push({
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
width: r * 2,
|
|
|
|
|
height: r * 2,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
background: 'rgba(255,255,255,0.45)',
|
|
|
|
|
[s ? 'bottom' : 'top']: base + i * gap,
|
|
|
|
|
[e ? 'right' : 'left']: base + j * gap,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return dots;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
return (
|
|
|
|
|
<CallEmbedContextProvider value={callEmbed}>
|
|
|
|
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
|
|
|
|
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
|
|
|
|
<div
|
|
|
|
|
data-call-embed-container
|
2026-05-14 22:50:20 -04:00
|
|
|
ref={callEmbedRef}
|
2026-03-07 18:03:32 +11:00
|
|
|
style={{
|
|
|
|
|
visibility: callVisible ? undefined : 'hidden',
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '50%',
|
|
|
|
|
}}
|
2026-05-14 22:50:20 -04:00
|
|
|
>
|
|
|
|
|
{pipMode && callEmbed && (
|
2026-05-14 23:07:29 -04:00
|
|
|
<>
|
|
|
|
|
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
|
2026-05-14 22:50:20 -04:00
|
|
|
<div
|
2026-05-14 23:07:29 -04:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label="Return to call"
|
|
|
|
|
onMouseDown={handlePipMouseDown}
|
|
|
|
|
onTouchStart={handlePipTouchStart}
|
|
|
|
|
onClick={() => handlePipClick(callEmbed.roomId)}
|
|
|
|
|
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
|
2026-05-14 22:50:20 -04:00
|
|
|
style={{
|
2026-05-14 23:07:29 -04:00
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
cursor: 'grab',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
justifyContent: 'flex-end',
|
|
|
|
|
padding: '6px',
|
2026-05-14 22:50:20 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2026-05-14 23:07:29 -04:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
background: 'rgba(0,0,0,0.65)',
|
|
|
|
|
backdropFilter: 'blur(4px)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '3px 8px',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
↗ Return to call
|
|
|
|
|
</div>
|
2026-05-14 22:50:20 -04:00
|
|
|
</div>
|
2026-05-14 23:07:29 -04:00
|
|
|
|
|
|
|
|
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
|
|
|
|
|
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
|
|
|
|
|
<div
|
|
|
|
|
key={corner}
|
|
|
|
|
style={cornerHandle(corner)}
|
|
|
|
|
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{gripDots(corner).map((style, i) => (
|
|
|
|
|
<div key={i} style={style} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
2026-05-14 22:50:20 -04:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 18:03:32 +11:00
|
|
|
</CallEmbedContextProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|