Extends ParticipantInfo with cachedUser: User | null. In updateParticipants, snapshots the previous participant list and carries forward the cached User object when spaceStore member lookup fails (user navigated to a different space). Local user falls back to authStore. homeUserId is now derived from cachedUser when the direct member lookup misses. Updates EMPTY_PARTICIPANT in PictureInPicture.tsx.
482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useVoiceStore } from '../../stores/voiceStore';
|
|
import { useChatStore } from '../../stores/chatStore';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
import { useSpaceStore } from '../../stores/spaceStore';
|
|
import { Avatar } from '../ui/Avatar';
|
|
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
|
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
|
|
|
/** Stable fallback for useVoiceParticipantMeta when no participant exists */
|
|
const EMPTY_PARTICIPANT: ParticipantInfo = {
|
|
identity: '', userId: '', username: '', homeUserId: null,
|
|
isMuted: false, isDeafened: false, isCameraOn: false, isScreenSharing: false,
|
|
isLocal: false, audioTrack: null, videoTrack: null, screenTrack: null,
|
|
screenAudioTrack: null, lkVideoTrack: null, lkScreenTrack: null,
|
|
cachedUser: null,
|
|
};
|
|
|
|
const PIP_WIDTH = 320;
|
|
const PIP_HEIGHT = 180;
|
|
const PIP_MARGIN = 16;
|
|
const DRAG_THRESHOLD = 5;
|
|
|
|
/**
|
|
* Computes PiP boundary box by measuring actual DOM obstacles.
|
|
* Obstacle elements declare themselves with data-pip-obstacle="left"|"bottom".
|
|
* PiP queries them at boundary-check time — no hardcoded layout values.
|
|
*
|
|
* On mobile (<768px) there are no fixed side/bottom UI obstacles.
|
|
*/
|
|
function getPipBounds(pipX: number): {
|
|
minX: number;
|
|
maxX: number;
|
|
minY: number;
|
|
maxY: number;
|
|
} {
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
let minX = PIP_MARGIN;
|
|
const maxX = vw - PIP_WIDTH - PIP_MARGIN;
|
|
const minY = PIP_MARGIN;
|
|
let maxY = vh - PIP_HEIGHT - PIP_MARGIN;
|
|
|
|
if (vw < 768) return { minX, maxX, minY, maxY };
|
|
|
|
const obstacles = document.querySelectorAll<HTMLElement>('[data-pip-obstacle]');
|
|
for (const el of obstacles) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) continue;
|
|
|
|
const direction = el.dataset.pipObstacle;
|
|
if (direction === 'left') {
|
|
minX = Math.max(minX, rect.right + PIP_MARGIN);
|
|
} else if (direction === 'bottom') {
|
|
const pipRight = pipX + PIP_WIDTH;
|
|
if (pipX < rect.right && pipRight > rect.left) {
|
|
maxY = Math.min(maxY, rect.top - PIP_HEIGHT - PIP_MARGIN);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { minX, maxX, minY, maxY };
|
|
}
|
|
|
|
interface SelectedStream {
|
|
participant: ParticipantInfo;
|
|
track: MediaStreamTrack;
|
|
type: 'screen' | 'camera';
|
|
}
|
|
|
|
function selectPipStream(
|
|
participants: ParticipantInfo[],
|
|
lastFocusedId: string | null,
|
|
watchingStreams: Set<string>,
|
|
): SelectedStream | null {
|
|
// Priority 1: Last-focused stream tile (identity:stream suffix → screen share)
|
|
if (lastFocusedId?.endsWith(':stream')) {
|
|
const identity = lastFocusedId.slice(0, -':stream'.length);
|
|
const focused = participants.find(p => p.identity === identity);
|
|
if (focused?.screenTrack) {
|
|
return { participant: focused, track: focused.screenTrack, type: 'screen' };
|
|
}
|
|
}
|
|
|
|
// Priority 2: Last-focused user tile (identity match → camera)
|
|
if (lastFocusedId && !lastFocusedId.endsWith(':stream')) {
|
|
const focused = participants.find(p => p.identity === lastFocusedId);
|
|
if (focused?.videoTrack) {
|
|
return { participant: focused, track: focused.videoTrack, type: 'camera' };
|
|
}
|
|
}
|
|
|
|
// Priority 3: Screen share from a user we're watching
|
|
const screenSharer = participants.find(
|
|
p => p.screenTrack !== null && watchingStreams.has(p.userId),
|
|
);
|
|
if (screenSharer?.screenTrack) {
|
|
return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' };
|
|
}
|
|
|
|
// Priority 4: Remote participant with camera
|
|
const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null);
|
|
if (remoteWithCamera?.videoTrack) {
|
|
return { participant: remoteWithCamera, track: remoteWithCamera.videoTrack, type: 'camera' };
|
|
}
|
|
|
|
// Priority 5: Local participant with camera
|
|
const localWithCamera = participants.find(p => p.isLocal && p.videoTrack !== null);
|
|
if (localWithCamera?.videoTrack) {
|
|
return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function PictureInPicture() {
|
|
const navigate = useNavigate();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Store state
|
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
|
const participants = useVoiceStore((s) => s.participants);
|
|
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
|
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
|
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
|
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
|
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
|
const pipCollapsed = useUIStore((s) => s.pipCollapsed);
|
|
const setPipCollapsed = useUIStore((s) => s.setPipCollapsed);
|
|
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
|
const channels = useSpaceStore((s) => s.channels);
|
|
|
|
// Drag state
|
|
const [position, setPosition] = useState<{ x: number; y: number }>({ x: -1, y: -1 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragOffset = useRef({ x: 0, y: 0 });
|
|
const dragStartPos = useRef({ x: 0, y: 0 });
|
|
const hasMoved = useRef(false);
|
|
|
|
// Reset pipCollapsed when joining a new call
|
|
const prevVoiceChannel = useRef(currentVoiceChannelId);
|
|
const prevDmCall = useRef(activeDmCall?.dmChannelId ?? null);
|
|
useEffect(() => {
|
|
const voiceChanged = currentVoiceChannelId !== prevVoiceChannel.current;
|
|
const dmChanged = (activeDmCall?.dmChannelId ?? null) !== prevDmCall.current;
|
|
prevVoiceChannel.current = currentVoiceChannelId;
|
|
prevDmCall.current = activeDmCall?.dmChannelId ?? null;
|
|
if ((voiceChanged && currentVoiceChannelId) || (dmChanged && activeDmCall)) {
|
|
setPipCollapsed(false);
|
|
}
|
|
}, [currentVoiceChannelId, activeDmCall, setPipCollapsed]);
|
|
|
|
// Visibility — split into wouldShow (ignores collapsed) and shouldShow (full check)
|
|
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
|
|
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId;
|
|
const wouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen;
|
|
const shouldShow = wouldShow && !pipCollapsed;
|
|
|
|
// Reset pipCollapsed when wouldShow transitions false → true
|
|
// (user navigated away from voice channel view → PiP reappears)
|
|
const prevWouldShow = useRef(wouldShow);
|
|
useEffect(() => {
|
|
if (wouldShow && !prevWouldShow.current) {
|
|
setPipCollapsed(false);
|
|
}
|
|
prevWouldShow.current = wouldShow;
|
|
}, [wouldShow, setPipCollapsed]);
|
|
|
|
// Track last speaker for avatar fallback (persists after they stop speaking)
|
|
const lastSpeakerRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
for (const id of speakingParticipantIds) {
|
|
const p = participants.find(pp => pp.identity === id && !pp.isLocal);
|
|
if (p) {
|
|
lastSpeakerRef.current = p.identity;
|
|
break;
|
|
}
|
|
}
|
|
}, [speakingParticipantIds, participants]);
|
|
|
|
// Track last non-null focusedParticipantId so PiP persists when grid focus clears
|
|
const lastFocusRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (focusedParticipantId) {
|
|
lastFocusRef.current = focusedParticipantId;
|
|
}
|
|
}, [focusedParticipantId]);
|
|
|
|
// Clear stale last-focus when the focused participant/track is gone
|
|
const effectiveLastFocus = useMemo(() => {
|
|
const id = lastFocusRef.current;
|
|
if (!id) return null;
|
|
if (id.endsWith(':stream')) {
|
|
const identity = id.slice(0, -':stream'.length);
|
|
const p = participants.find(pp => pp.identity === identity);
|
|
if (p?.screenTrack) return id;
|
|
} else {
|
|
const p = participants.find(pp => pp.identity === id);
|
|
if (p?.videoTrack) return id;
|
|
}
|
|
// Focused content is gone — clear ref
|
|
lastFocusRef.current = null;
|
|
return null;
|
|
}, [participants]);
|
|
|
|
// Stream selection
|
|
const selectedStream = useMemo(
|
|
() => selectPipStream(participants, effectiveLastFocus, watchingStreams),
|
|
[participants, effectiveLastFocus, watchingStreams],
|
|
);
|
|
|
|
// Fallback participant for avatar (last speaker > first remote > local)
|
|
const fallbackParticipant = useMemo(() => {
|
|
// Currently speaking remote
|
|
const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity));
|
|
if (speaking) return speaking;
|
|
// Last speaker (persists after they stop)
|
|
if (lastSpeakerRef.current) {
|
|
const last = participants.find(p => p.identity === lastSpeakerRef.current);
|
|
if (last) return last;
|
|
}
|
|
// Any remote
|
|
const remote = participants.find(p => !p.isLocal);
|
|
if (remote) return remote;
|
|
return participants[0] ?? null;
|
|
}, [participants, speakingParticipantIds]);
|
|
|
|
// Channel name for display
|
|
const channelName = useMemo(() => {
|
|
if (currentVoiceChannelId) {
|
|
const ch = channels.find(c => c.id === currentVoiceChannelId);
|
|
return ch?.name ?? 'Voice';
|
|
}
|
|
return 'Call';
|
|
}, [currentVoiceChannelId, channels]);
|
|
|
|
// Derive the LiveKit Track from the selected stream's participant
|
|
const lkTrack = selectedStream
|
|
? (selectedStream.type === 'screen'
|
|
? selectedStream.participant.lkScreenTrack
|
|
: selectedStream.participant.lkVideoTrack)
|
|
: null;
|
|
|
|
// Video track attachment — use LiveKit's track.attach() to register the element
|
|
// with the adaptive stream observer (enables SFU layer switching by viewport size)
|
|
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
|
|
useEffect(() => {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return;
|
|
if (lkTrack) {
|
|
lkTrack.attach(videoEl);
|
|
return () => { lkTrack.detach(videoEl); };
|
|
} else {
|
|
videoEl.srcObject = null;
|
|
}
|
|
}, [lkTrack, shouldShow]);
|
|
|
|
// Initialize position to bottom-right
|
|
useEffect(() => {
|
|
if (shouldShow && position.x === -1) {
|
|
const initX = window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
|
const { maxY } = getPipBounds(initX);
|
|
setPosition({ x: initX, y: maxY });
|
|
}
|
|
}, [shouldShow, position.x]);
|
|
|
|
// Re-clamp PiP when viewport resizes, obstacles appear/disappear, or obstacles resize
|
|
useEffect(() => {
|
|
if (!shouldShow) return;
|
|
|
|
const reclamp = () => {
|
|
setPosition(prev => {
|
|
if (prev.x === -1) return prev;
|
|
const { minX, maxX } = getPipBounds(prev.x);
|
|
const clampedX = Math.max(minX, Math.min(maxX, prev.x));
|
|
const { minY, maxY } = getPipBounds(clampedX);
|
|
return { x: clampedX, y: Math.max(minY, Math.min(maxY, prev.y)) };
|
|
});
|
|
};
|
|
|
|
// Observe obstacle elements for size changes (e.g., voice panel expanding)
|
|
let knownObstacles = new Set(document.querySelectorAll<HTMLElement>('[data-pip-obstacle]'));
|
|
const resizeObserver = new ResizeObserver(reclamp);
|
|
knownObstacles.forEach(el => resizeObserver.observe(el));
|
|
|
|
// Detect obstacle elements being added/removed from the DOM
|
|
const mutationObserver = new MutationObserver(() => {
|
|
const current = new Set(document.querySelectorAll<HTMLElement>('[data-pip-obstacle]'));
|
|
if (current.size !== knownObstacles.size || ![...current].every(el => knownObstacles.has(el))) {
|
|
resizeObserver.disconnect();
|
|
current.forEach(el => resizeObserver.observe(el));
|
|
knownObstacles = current;
|
|
reclamp();
|
|
}
|
|
});
|
|
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
|
|
window.addEventListener('resize', reclamp);
|
|
return () => {
|
|
window.removeEventListener('resize', reclamp);
|
|
mutationObserver.disconnect();
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [shouldShow]);
|
|
|
|
// Snap to nearest horizontal edge
|
|
const snapToEdge = useCallback((currentX: number, currentY: number) => {
|
|
const centerX = currentX + PIP_WIDTH / 2;
|
|
const screenMidX = window.innerWidth / 2;
|
|
const { minX } = getPipBounds(currentX);
|
|
const targetX = centerX < screenMidX
|
|
? minX
|
|
: window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
|
const { minY, maxY } = getPipBounds(targetX);
|
|
const clampedY = Math.max(minY, Math.min(maxY, currentY));
|
|
setPosition({ x: targetX, y: clampedY });
|
|
}, []);
|
|
|
|
// Drag handlers
|
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
if ((e.target as HTMLElement).closest('[data-pip-action]')) return;
|
|
setIsDragging(true);
|
|
hasMoved.current = false;
|
|
dragOffset.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
|
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
|
containerRef.current?.setPointerCapture(e.pointerId);
|
|
}, [position]);
|
|
|
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
|
if (!isDragging) return;
|
|
const dx = Math.abs(e.clientX - dragStartPos.current.x);
|
|
const dy = Math.abs(e.clientY - dragStartPos.current.y);
|
|
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
|
|
hasMoved.current = true;
|
|
}
|
|
const rawX = e.clientX - dragOffset.current.x;
|
|
const bounds = getPipBounds(rawX);
|
|
const newX = Math.max(bounds.minX, Math.min(bounds.maxX, rawX));
|
|
const { minY, maxY } = getPipBounds(newX);
|
|
const newY = Math.max(minY, Math.min(maxY, e.clientY - dragOffset.current.y));
|
|
setPosition({ x: newX, y: newY });
|
|
}, [isDragging]);
|
|
|
|
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
|
if (!isDragging) return;
|
|
setIsDragging(false);
|
|
containerRef.current?.releasePointerCapture(e.pointerId);
|
|
|
|
if (!hasMoved.current) {
|
|
// Click — navigate back to voice channel
|
|
if (activeDmCall) {
|
|
navigate(`/channels/@me/${activeDmCall.dmChannelId}`);
|
|
} else if (currentVoiceChannelId) {
|
|
const spaceId = channelToSpaceMap.get(currentVoiceChannelId);
|
|
if (spaceId) {
|
|
navigate(`/channels/${spaceId}/${currentVoiceChannelId}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Drag ended — snap to edge
|
|
snapToEdge(position.x, position.y);
|
|
}
|
|
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToSpaceMap, navigate, snapToEdge, position]);
|
|
|
|
const handleClose = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setPipCollapsed(true);
|
|
}, [setPipCollapsed]);
|
|
|
|
// Compute displayParticipant before early return so hook is called unconditionally
|
|
const displayParticipant = useMemo(
|
|
() => selectedStream?.participant ?? fallbackParticipant,
|
|
[selectedStream, fallbackParticipant],
|
|
);
|
|
|
|
const { displayName: resolvedName, avatar: resolvedAvatar, user: resolvedUser } =
|
|
useVoiceParticipantMeta(displayParticipant ?? EMPTY_PARTICIPANT);
|
|
|
|
if (!shouldShow) return null;
|
|
|
|
const displayName = displayParticipant
|
|
? (displayParticipant.isLocal ? `${resolvedName} (You)` : resolvedName)
|
|
: channelName;
|
|
const hasVideo = selectedStream !== null;
|
|
const isScreen = selectedStream?.type === 'screen';
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`fixed z-[40] overflow-hidden rounded-lg shadow-2xl ring-1 ring-white/10 bg-surface-base select-none ${
|
|
isDragging ? 'cursor-grabbing' : 'cursor-grab'
|
|
}`}
|
|
style={{
|
|
width: PIP_WIDTH,
|
|
height: PIP_HEIGHT,
|
|
left: position.x,
|
|
top: position.y,
|
|
transition: isDragging ? 'none' : 'left 0.2s ease, top 0.2s ease',
|
|
touchAction: 'none',
|
|
}}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
{/* Video or Avatar fallback */}
|
|
{hasVideo ? (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className="w-full h-full object-cover"
|
|
style={{ imageRendering: 'auto' }}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center bg-surface-channel">
|
|
{displayParticipant ? (
|
|
<div className="relative flex">
|
|
<Avatar
|
|
src={resolvedAvatar}
|
|
name={resolvedName}
|
|
size={64}
|
|
userId={displayParticipant.homeUserId ?? displayParticipant.userId}
|
|
user={resolvedUser ?? undefined}
|
|
/>
|
|
{speakingParticipantIds.has(displayParticipant.identity) && (
|
|
<div className="absolute -inset-1 rounded-full ring-2 ring-status-online animate-pulse" />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-txt-tertiary">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
|
|
</svg>
|
|
<span className="text-sm font-medium">{channelName}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* LIVE badge */}
|
|
{isScreen && (
|
|
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-accent-rose rounded text-[11px] font-bold text-white uppercase tracking-wide">
|
|
LIVE
|
|
</div>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
data-pip-action="close"
|
|
onClick={handleClose}
|
|
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Bottom overlay with name */}
|
|
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-white text-xs font-semibold truncate">{displayName}</span>
|
|
{displayParticipant && speakingParticipantIds.has(displayParticipant.identity) && (
|
|
<div className="w-2 h-2 rounded-full bg-status-online flex-shrink-0 animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div className="text-white/50 text-[10px] truncate">{channelName}</div>
|
|
</div>
|
|
|
|
{/* Expand icon hint (bottom-right) */}
|
|
<div className="absolute bottom-2 right-2 w-5 h-5 flex items-center justify-center text-white/40">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|