From 2e309bf959eea6e4b94f688a6e5b3f2d35e0f870 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:26:06 +0100 Subject: [PATCH] feat: Add Picture-in-Picture floating video + fix local track visibility - New PictureInPicture component: draggable floating window that shows active stream when navigating away from voice channel, with LIVE badge, snap-to-edge, click-to-navigate-back - Fix VoiceUser to check actual track presence instead of boolean flags for video rendering - Add LocalTrackPublished/LocalTrackUnpublished events to useLiveKit so local screen share and camera show immediately in VoiceGrid - Add pipCollapsed state to uiStore --- .../web/src/components/layout/AppLayout.js | 3 +- .../web/src/components/layout/AppLayout.tsx | 2 + .../src/components/voice/PictureInPicture.js | 203 ++++++++++++ .../src/components/voice/PictureInPicture.tsx | 310 ++++++++++++++++++ .../web/src/components/voice/VoiceUser.js | 2 +- .../web/src/components/voice/VoiceUser.tsx | 2 +- packages/web/src/hooks/useLiveKit.js | 11 + packages/web/src/hooks/useLiveKit.ts | 10 + packages/web/src/stores/uiStore.js | 2 + packages/web/src/stores/uiStore.ts | 4 + 10 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 packages/web/src/components/voice/PictureInPicture.js create mode 100644 packages/web/src/components/voice/PictureInPicture.tsx diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index 0d24abe5..f8eec4a4 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -14,6 +14,7 @@ import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; +import { PictureInPicture } from '../voice/PictureInPicture'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -100,5 +101,5 @@ export function AppLayout() { if (isLoading || !user) { return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) })); } - return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] })); + return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] })); } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 28d7f3f3..b9edf6af 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -14,6 +14,7 @@ import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; +import { PictureInPicture } from '../voice/PictureInPicture'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -161,6 +162,7 @@ export function AppLayout() { + {/* User Profile Popout */} {userProfilePopout.user && userProfilePopout.position && ( diff --git a/packages/web/src/components/voice/PictureInPicture.js b/packages/web/src/components/voice/PictureInPicture.js new file mode 100644 index 00000000..7b9fd027 --- /dev/null +++ b/packages/web/src/components/voice/PictureInPicture.js @@ -0,0 +1,203 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { 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'; +const PIP_WIDTH = 320; +const PIP_HEIGHT = 180; +const PIP_MARGIN = 16; +const DRAG_THRESHOLD = 5; +function selectPipStream(participants, focusedId) { + // Priority 1: Screen share (highest value content) + const screenSharer = participants.find(p => p.screenTrack !== null); + 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(null); + const containerRef = useRef(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 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: -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), [participants, focusedParticipantId]); + // Fallback participant for avatar (most relevant remote, or first participant) + const fallbackParticipant = useMemo(() => { + const speaking = participants.find(p => !p.isLocal && p.isSpeaking); + if (speaking) + return speaking; + const remote = participants.find(p => !p.isLocal); + if (remote) + return remote; + return participants[0] ?? null; + }, [participants]); + // 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]); + // Video track attachment + useEffect(() => { + const videoEl = videoRef.current; + if (!videoEl) + return; + if (selectedStream?.track) { + videoEl.srcObject = new MediaStream([selectedStream.track]); + } + else { + videoEl.srcObject = null; + } + }, [selectedStream?.track]); + // 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, currentY) => { + 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) => { + if (e.target.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) => { + 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) => { + 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) => { + 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 (_jsxs("div", { ref: containerRef, className: `fixed z-[40] overflow-hidden rounded-lg shadow-2xl ring-1 ring-white/10 bg-[#111214] 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, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover", style: { imageRendering: 'auto' } })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center bg-[#2b2d31]", children: displayParticipant ? (_jsxs("div", { className: "relative", children: [_jsx(Avatar, { name: displayParticipant.username, size: 64 }), displayParticipant.isSpeaking && (_jsx("div", { className: "absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" }))] })) : (_jsxs("div", { className: "flex items-center gap-2 text-discord-text-muted", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }), _jsx("span", { className: "text-sm font-medium", children: channelName })] })) })), isScreen && (_jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" })), _jsx("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", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }), _jsxs("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-white text-xs font-semibold truncate", children: displayName }), displayParticipant?.isSpeaking && (_jsx("div", { className: "w-2 h-2 rounded-full bg-discord-green flex-shrink-0 animate-pulse" }))] }), _jsx("div", { className: "text-white/50 text-[10px] truncate", children: channelName })] }), _jsx("div", { className: "absolute bottom-2 right-2 w-5 h-5 flex items-center justify-center text-white/40", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z" }) }) })] })); +} diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx new file mode 100644 index 00000000..56160b12 --- /dev/null +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -0,0 +1,310 @@ +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, +): SelectedStream | null { + // Priority 1: Screen share (highest value content) + const screenSharer = participants.find(p => p.screenTrack !== null); + 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(null); + const containerRef = useRef(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 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), + [participants, focusedParticipantId], + ); + + // Fallback participant for avatar (most relevant remote, or first participant) + const fallbackParticipant = useMemo(() => { + const speaking = participants.find(p => !p.isLocal && p.isSpeaking); + if (speaking) return speaking; + const remote = participants.find(p => !p.isLocal); + if (remote) return remote; + return participants[0] ?? null; + }, [participants]); + + // 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]); + + // Video track attachment + useEffect(() => { + const videoEl = videoRef.current; + if (!videoEl) return; + if (selectedStream?.track) { + videoEl.srcObject = new MediaStream([selectedStream.track]); + } else { + videoEl.srcObject = null; + } + }, [selectedStream?.track]); + + // 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 ( +
+ {/* Video or Avatar fallback */} + {hasVideo ? ( +