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
This commit is contained in:
@@ -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 })] }))] }));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<NewDmModal />
|
||||
<IncomingCallModal />
|
||||
<ImagePreview />
|
||||
<PictureInPicture />
|
||||
|
||||
{/* User Profile Popout */}
|
||||
{userProfilePopout.user && userProfilePopout.position && (
|
||||
|
||||
@@ -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" }) }) })] }));
|
||||
}
|
||||
@@ -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<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 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 (
|
||||
<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}
|
||||
>
|
||||
{/* 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-[#2b2d31]">
|
||||
{displayParticipant ? (
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
name={displayParticipant.username}
|
||||
size={64}
|
||||
/>
|
||||
{displayParticipant.isSpeaking && (
|
||||
<div className="absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-discord-text-muted">
|
||||
<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-discord-red 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?.isSpeaking && (
|
||||
<div className="w-2 h-2 rounded-full bg-discord-green 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>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function VoiceUser({ participant, large }) {
|
||||
}
|
||||
audioEl.muted = isDeafened;
|
||||
}, [isDeafened, outputVolume, perUserVolume]);
|
||||
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
|
||||
const hasVideo = !!(participant.videoTrack || participant.screenTrack);
|
||||
const isLocal = participant.isLocal;
|
||||
// Volume context menu
|
||||
const [volumeMenu, setVolumeMenu] = useState(null);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
audioEl.muted = isDeafened;
|
||||
}, [isDeafened, outputVolume, perUserVolume]);
|
||||
|
||||
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
|
||||
const hasVideo = !!(participant.videoTrack || participant.screenTrack);
|
||||
const isLocal = participant.isLocal;
|
||||
|
||||
// Volume context menu
|
||||
|
||||
@@ -119,6 +119,9 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
const connected = state === ConnectionState.Connected;
|
||||
@@ -181,6 +184,14 @@ export function useLiveKit() {
|
||||
return;
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||
roomRef.current = newRoom;
|
||||
const guardedUpdate = () => { if (roomRef.current === newRoom)
|
||||
updateParticipants(); };
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
const connected = state === ConnectionState.Connected;
|
||||
|
||||
@@ -142,6 +142,9 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
const connected = state === ConnectionState.Connected;
|
||||
@@ -174,6 +177,13 @@ export function useLiveKit() {
|
||||
if (gen !== _connectGeneration) return;
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||
roomRef.current = newRoom;
|
||||
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
const connected = state === ConnectionState.Connected;
|
||||
|
||||
@@ -31,7 +31,9 @@ export const useUIStore = create((set) => ({
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
|
||||
@@ -36,9 +36,11 @@ interface UIState {
|
||||
closeUserProfile: () => void;
|
||||
voiceChatOpen: boolean;
|
||||
voiceFullscreen: boolean;
|
||||
pipCollapsed: boolean;
|
||||
toggleVoiceChat: () => void;
|
||||
toggleVoiceFullscreen: () => void;
|
||||
setVoiceFullscreen: (fullscreen: boolean) => void;
|
||||
setPipCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@@ -80,7 +82,9 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user