Files
backspace/packages/web/src/components/voice/PictureInPicture.tsx
T
Jannis Braun 7172091159 feat: Phase 3 Aether Drift — voice & video component token migration
Migrate all 10 voice/video components from Discord visual language to
Aether Drift design system. Zero discord-* classes or hardcoded hex
values remain in components/voice/.

Key changes:
- Status indicators use pastel accents: mint (connected/speaking),
  amber (connecting/idle), rose (muted/deafened/error)
- VoiceControlBar converted to frosted glass-bubble material
- ConnectionInfoPopover and ScreenShareSettingsPopover use glass popovers
- Speaking ring glow changed from Discord green to mint (#86efac)
- Slider accents use lavender instead of blurple
- All bg/text/border tokens mapped to surface-*/txt-*/interactive-* system
2026-03-01 23:36:10 +01:00

326 lines
13 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 { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
const PIP_WIDTH = 320;
const PIP_HEIGHT = 180;
const PIP_MARGIN = 16;
const DRAG_THRESHOLD = 5;
interface SelectedStream {
participant: ParticipantInfo;
track: MediaStreamTrack;
type: 'screen' | 'camera';
}
function selectPipStream(
participants: ParticipantInfo[],
focusedId: string | null,
watchingStreams: Set<string>,
): SelectedStream | null {
// Priority 1: 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 2: Focused participant with camera
if (focusedId) {
const focused = participants.find(p => p.identity === focusedId);
if (focused?.videoTrack) {
return { participant: focused, track: focused.videoTrack, type: 'camera' };
}
}
// Priority 3: 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 4: 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 channelToServerMap = useServerStore((s) => s.channelToServerMap);
const channels = useServerStore((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
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId;
const shouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen && !pipCollapsed;
// Stream selection
const selectedStream = useMemo(
() => selectPipStream(participants, focusedParticipantId, watchingStreams),
[participants, focusedParticipantId, watchingStreams],
);
// Fallback participant for avatar (most relevant remote, or first participant)
const fallbackParticipant = useMemo(() => {
const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity));
if (speaking) return speaking;
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) {
setPosition({
x: window.innerWidth - PIP_WIDTH - PIP_MARGIN,
y: window.innerHeight - PIP_HEIGHT - PIP_MARGIN,
});
}
}, [shouldShow, position.x]);
// Window resize: keep PiP in bounds
useEffect(() => {
if (!shouldShow) return;
const handleResize = () => {
setPosition(prev => ({
x: Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, prev.x)),
y: Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, prev.y)),
}));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [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 targetX = centerX < screenMidX
? PIP_MARGIN
: window.innerWidth - PIP_WIDTH - PIP_MARGIN;
const clampedY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, 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 newX = Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, e.clientX - dragOffset.current.x));
const newY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, 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 serverId = channelToServerMap.get(currentVoiceChannelId);
if (serverId) {
navigate(`/channels/${serverId}/${currentVoiceChannelId}`);
}
}
} else {
// Drag ended — snap to edge
snapToEdge(position.x, position.y);
}
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToServerMap, navigate, snapToEdge, position]);
const handleClose = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setPipCollapsed(true);
}, [setPipCollapsed]);
if (!shouldShow) return null;
const displayParticipant = selectedStream?.participant ?? fallbackParticipant;
const displayName = displayParticipant
? (displayParticipant.isLocal ? `${displayParticipant.username} (You)` : displayParticipant.username)
: 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">
<Avatar
name={displayParticipant.username}
size={64}
/>
{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>
);
}