Files
cinny/src/app/components/CallEmbedProvider.tsx
T

246 lines
8.1 KiB
TypeScript
Raw Normal View History

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';
import { config } from 'folds';
2026-03-07 18:03:32 +11:00
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
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
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
useCallMemberSoundSync(embed);
2026-03-07 18:03:32 +11:00
useCallThemeSync(embed);
useCallHangupEvent(
embed,
useCallback(() => {
setCallEmbed(undefined);
}, [setCallEmbed])
);
return null;
}
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
// Tracks whether the current pointer-down turned into a drag vs a tap/click.
const pipDragRef = useRef<{
startX: number;
startY: number;
origLeft: number;
origTop: number;
dragged: boolean;
} | null>(null);
// useCallEmbedPlacementSync writes top/left/width/height as inline styles directly on this
// element. Override those when in PiP mode; let them be restored when returning to the call room.
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 {
// Clear all PiP overrides so useCallEmbedPlacementSync can take back control.
el.style.top = '';
el.style.left = '';
el.style.bottom = '';
el.style.right = '';
el.style.borderRadius = '';
el.style.overflow = '';
el.style.zIndex = '';
el.style.boxShadow = '';
el.style.border = '';
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
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 = '';
// Defer clearing the dragged flag so the onClick handler fires first.
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
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 && (
<div
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)}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
cursor: 'grab',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '6px',
}}
>
<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>
</div>
)}
</div>
2026-03-07 18:03:32 +11:00
</CallEmbedContextProvider>
);
}