feat: introduce a comprehensive LiveKit-based voice and video chat system with state management and screen sharing capabilities.
All checks were successful
Build and Release / build-and-release (push) Successful in 10m6s
All checks were successful
Build and Release / build-and-release (push) Successful in 10m6s
This commit is contained in:
File diff suppressed because one or more lines are too long
1
Frontend/Electron/dist-react/assets/index-CFE9j0dq.css
Normal file
1
Frontend/Electron/dist-react/assets/index-CFE9j0dq.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>discord</title>
|
||||
<script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css">
|
||||
<script type="module" crossorigin src="./assets/index-BeX08324.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CFE9j0dq.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"private": true,
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "A Discord clone built with Convex, React, and Electron",
|
||||
"author": "Moyettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -118,7 +118,9 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
display: 'flex', allignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -700,6 +700,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
onTogglePinned();
|
||||
}, [channelId, channelKey]);
|
||||
|
||||
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
||||
|
||||
const isMentionedInContent = useCallback((content) => {
|
||||
if (!content) return false;
|
||||
return content.includes(`@${username}`) ||
|
||||
myRoleNames.some(rn =>
|
||||
rn.startsWith('@') ? content.includes(rn) : content.includes(`@role:${rn}`)
|
||||
);
|
||||
}, [username, myRoleNames]);
|
||||
|
||||
const playPingSound = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastPingTimeRef.current < 1000) return;
|
||||
lastPingTimeRef.current = now;
|
||||
const audio = new Audio(PingSound);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Play ping sound when a new message mentions us (by username or role)
|
||||
useEffect(() => {
|
||||
if (!decryptedMessages.length) return;
|
||||
@@ -795,25 +814,6 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
...filteredMentionRoles.map(r => ({ type: 'role', ...r })),
|
||||
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
||||
];
|
||||
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
||||
|
||||
const isMentionedInContent = useCallback((content) => {
|
||||
if (!content) return false;
|
||||
return content.includes(`@${username}`) ||
|
||||
myRoleNames.some(rn =>
|
||||
rn.startsWith('@') ? content.includes(rn) : content.includes(`@role:${rn}`)
|
||||
);
|
||||
}, [username, myRoleNames]);
|
||||
|
||||
const playPingSound = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastPingTimeRef.current < 1000) return;
|
||||
lastPingTimeRef.current = now;
|
||||
const audio = new Audio(PingSound);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((force = false) => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
269
Frontend/Electron/src/components/FloatingStreamPiP.jsx
Normal file
269
Frontend/Electron/src/components/FloatingStreamPiP.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { VideoRenderer, useParticipantTrack } from '../utils/streamUtils.jsx';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
|
||||
const MIN_WIDTH = 240;
|
||||
const MIN_HEIGHT = 135;
|
||||
const MAX_WIDTH_RATIO = 0.75;
|
||||
const MAX_HEIGHT_RATIO = 0.75;
|
||||
const DEFAULT_WIDTH = 320;
|
||||
const DEFAULT_HEIGHT = 180;
|
||||
const ASPECT_RATIO = 16 / 9;
|
||||
|
||||
const LIVE_BADGE_STYLE = {
|
||||
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
|
||||
color: 'white', fontSize: '10px', fontWeight: 'bold',
|
||||
textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
const FloatingStreamPiP = ({ onGoBackToStream }) => {
|
||||
const { room, watchingStreamOf, setWatchingStreamOf, voiceStates, activeChannelId } = useVoice();
|
||||
const pipUserId = localStorage.getItem('userId');
|
||||
|
||||
const [position, setPosition] = useState(() => getUserPref(pipUserId, 'pipPosition', { x: -1, y: -1 }));
|
||||
const [size, setSize] = useState(() => getUserPref(pipUserId, 'pipSize', { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }));
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const isResizing = useRef(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const resizeStart = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Initialize position to bottom-right on mount (only if no saved position)
|
||||
useEffect(() => {
|
||||
if (position.x === -1 && position.y === -1) {
|
||||
setPosition({
|
||||
x: window.innerWidth - size.width - 24,
|
||||
y: window.innerHeight - size.height - 24,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Find the watched participant from the room
|
||||
const participant = (() => {
|
||||
if (!room || !watchingStreamOf) return null;
|
||||
if (room.localParticipant.identity === watchingStreamOf) return room.localParticipant;
|
||||
return room.remoteParticipants.get(watchingStreamOf) || null;
|
||||
})();
|
||||
|
||||
const screenTrack = useParticipantTrack(participant, 'screenshare');
|
||||
|
||||
// Resolve streamer username from voiceStates
|
||||
const streamerUsername = (() => {
|
||||
if (!watchingStreamOf) return '';
|
||||
for (const users of Object.values(voiceStates)) {
|
||||
const u = users.find(u => u.userId === watchingStreamOf);
|
||||
if (u) return u.username;
|
||||
}
|
||||
return watchingStreamOf;
|
||||
})();
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = useCallback((e) => {
|
||||
if (isResizing.current) return;
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
dragOffset.current = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (isDragging.current) {
|
||||
let newX = e.clientX - dragOffset.current.x;
|
||||
let newY = e.clientY - dragOffset.current.y;
|
||||
|
||||
// Constrain to window bounds
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - size.width));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - size.height));
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
|
||||
if (isResizing.current) {
|
||||
const dx = e.clientX - resizeStart.current.x;
|
||||
const dy = e.clientY - resizeStart.current.y;
|
||||
|
||||
// Use the larger delta to maintain aspect ratio
|
||||
const maxW = window.innerWidth * MAX_WIDTH_RATIO;
|
||||
const maxH = window.innerHeight * MAX_HEIGHT_RATIO;
|
||||
|
||||
let newWidth = resizeStart.current.width + dx;
|
||||
newWidth = Math.max(MIN_WIDTH, Math.min(maxW, newWidth));
|
||||
let newHeight = newWidth / ASPECT_RATIO;
|
||||
|
||||
if (newHeight > maxH) {
|
||||
newHeight = maxH;
|
||||
newWidth = newHeight * ASPECT_RATIO;
|
||||
}
|
||||
if (newHeight < MIN_HEIGHT) {
|
||||
newHeight = MIN_HEIGHT;
|
||||
newWidth = newHeight * ASPECT_RATIO;
|
||||
}
|
||||
|
||||
setSize({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
isResizing.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
resizeStart.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
// Debounced persist of PiP position and size
|
||||
useEffect(() => {
|
||||
if (position.x === -1 && position.y === -1) return;
|
||||
const timer = setTimeout(() => {
|
||||
setUserPref(pipUserId, 'pipPosition', position);
|
||||
setUserPref(pipUserId, 'pipSize', size);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [position, size, pipUserId]);
|
||||
|
||||
const handleStopWatching = useCallback(() => {
|
||||
setWatchingStreamOf(null);
|
||||
}, [setWatchingStreamOf]);
|
||||
|
||||
if (!watchingStreamOf || !participant) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
zIndex: 1000,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
backgroundColor: 'black',
|
||||
cursor: isDragging.current ? 'grabbing' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
{/* Video content */}
|
||||
{screenTrack ? (
|
||||
<VideoRenderer track={screenTrack} style={{ objectFit: 'contain', pointerEvents: 'none' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#72767d', fontSize: '13px',
|
||||
}}>
|
||||
Loading stream...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
{hovering && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px',
|
||||
transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{/* Top row: streamer name + back button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ color: 'white', fontSize: '12px', fontWeight: '600' }}>
|
||||
{streamerUsername}
|
||||
</span>
|
||||
<span style={LIVE_BADGE_STYLE}>LIVE</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoBackToStream(); }}
|
||||
title="Back to Stream"
|
||||
style={{
|
||||
width: '28px', height: '28px', borderRadius: '4px',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
color: 'white', fontSize: '16px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.25)'}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.15)'}
|
||||
>
|
||||
{/* Back arrow icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6.5 12.5L2 8L6.5 3.5M2.5 8H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: stop watching */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStopWatching(); }}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
|
||||
padding: '6px 14px', borderRadius: '4px', fontWeight: '600',
|
||||
fontSize: '12px', cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
Stop Watching
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resize handle (bottom-right corner) */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'nwse-resize',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{/* Diagonal grip lines */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ opacity: hovering ? 0.6 : 0 , transition: 'opacity 0.15s' }}>
|
||||
<line x1="14" y1="4" x2="4" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
<line x1="14" y1="8" x2="8" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
<line x1="14" y1="12" x2="12" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingStreamPiP;
|
||||
@@ -30,6 +30,7 @@ import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
|
||||
import PingSound from '../assets/sounds/ping.mp3';
|
||||
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
|
||||
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
@@ -376,7 +377,7 @@ function getScreenCaptureConstraints(selection) {
|
||||
};
|
||||
}
|
||||
|
||||
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage }) => {
|
||||
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage, isSelf, userVolume, onVolumeChange }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
@@ -397,8 +398,30 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
|
||||
setPos({ top: newTop, left: newLeft });
|
||||
}, [x, y]);
|
||||
|
||||
const sliderPercent = (userVolume / 200) * 100;
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
||||
{!isSelf && (
|
||||
<>
|
||||
<div className="context-menu-volume" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="context-menu-volume-label">
|
||||
<span>User Volume</span>
|
||||
<span>{userVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={userVolume}
|
||||
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||
className="context-menu-volume-slider"
|
||||
style={{ background: `linear-gradient(to right, hsl(235 86% 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-separator" />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="context-menu-item context-menu-checkbox-item"
|
||||
role="menuitemcheckbox"
|
||||
@@ -726,7 +749,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const [newChannelType, setNewChannelType] = useState('text');
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||
const [collapsedCategories, setCollapsedCategories] = useState(() => getUserPref(userId, 'collapsedCategories', {}));
|
||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||
@@ -815,7 +838,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
if (activeChannel === id) onSelectChannel(null);
|
||||
};
|
||||
|
||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings } = useVoice();
|
||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings, getUserVolume, setUserVolume } = useVoice();
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setIsCreating(true);
|
||||
@@ -1084,7 +1107,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
};
|
||||
|
||||
const toggleCategory = (cat) => {
|
||||
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||
setCollapsedCategories(prev => {
|
||||
const next = { ...prev, [cat]: !prev[cat] };
|
||||
setUserPref(userId, 'collapsedCategories', next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group channels by categoryId
|
||||
@@ -1590,6 +1617,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
y={voiceUserMenu.y}
|
||||
user={voiceUserMenu.user}
|
||||
onClose={() => setVoiceUserMenu(null)}
|
||||
isSelf={voiceUserMenu.user.userId === userId}
|
||||
isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)}
|
||||
onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)}
|
||||
isServerMuted={isServerMuted(voiceUserMenu.user.userId)}
|
||||
@@ -1599,6 +1627,8 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
|
||||
onViewChange('me');
|
||||
}}
|
||||
userVolume={getUserVolume(voiceUserMenu.user.userId)}
|
||||
onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)}
|
||||
/>
|
||||
)}
|
||||
{showCreateChannelModal && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Track, RoomEvent } from 'livekit-client';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import Avatar from './Avatar';
|
||||
import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
|
||||
|
||||
// Icons
|
||||
import muteIcon from '../assets/icons/mute.svg';
|
||||
@@ -66,124 +67,6 @@ const ColoredIcon = ({ src, color, size = '24px' }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const VideoRenderer = ({ track, style }) => {
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (el && track) {
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}
|
||||
}, [track]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black', ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Track Discovery Helpers ---
|
||||
|
||||
function findTrackPubs(participant) {
|
||||
let cameraPub = null;
|
||||
let screenSharePub = null;
|
||||
let screenShareAudioPub = null;
|
||||
|
||||
const trackMap = participant.tracks || participant.trackPublications;
|
||||
if (trackMap) {
|
||||
for (const pub of trackMap.values()) {
|
||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
|
||||
|
||||
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
|
||||
screenShareAudioPub = pub;
|
||||
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
|
||||
screenSharePub = pub;
|
||||
} else if (source === 'camera' || name.includes('camera')) {
|
||||
cameraPub = pub;
|
||||
} else if (pub.kind === 'video' && !screenSharePub) {
|
||||
screenSharePub = pub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for older SDKs
|
||||
if ((!screenSharePub && !cameraPub) && participant.getTracks) {
|
||||
try {
|
||||
for (const pub of participant.getTracks()) {
|
||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
|
||||
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
|
||||
screenShareAudioPub = pub;
|
||||
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
|
||||
screenSharePub = pub;
|
||||
} else if (source === 'camera' || name.includes('camera')) {
|
||||
cameraPub = pub;
|
||||
} else if (pub.kind === 'video' && !screenSharePub) {
|
||||
screenSharePub = pub;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return { cameraPub, screenSharePub, screenShareAudioPub };
|
||||
}
|
||||
|
||||
function useParticipantTrack(participant, source) {
|
||||
const [track, setTrack] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!participant) { setTrack(null); return; }
|
||||
|
||||
const resolve = () => {
|
||||
const { cameraPub, screenSharePub } = findTrackPubs(participant);
|
||||
const pub = source === 'camera' ? cameraPub : screenSharePub;
|
||||
|
||||
if (pub && !pub.isSubscribed && source === 'camera') {
|
||||
pub.setSubscribed(true);
|
||||
}
|
||||
|
||||
setTrack(pub?.track || null);
|
||||
};
|
||||
|
||||
resolve();
|
||||
const interval = setInterval(resolve, 1000);
|
||||
const timeout = setTimeout(() => clearInterval(interval), 10000);
|
||||
|
||||
const onPub = () => resolve();
|
||||
const onUnpub = () => resolve();
|
||||
|
||||
participant.on(RoomEvent.TrackPublished, onPub);
|
||||
participant.on(RoomEvent.TrackUnpublished, onUnpub);
|
||||
participant.on(RoomEvent.TrackSubscribed, onPub);
|
||||
participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
|
||||
participant.on(RoomEvent.TrackMuted, onPub);
|
||||
participant.on(RoomEvent.TrackUnmuted, onPub);
|
||||
participant.on('localTrackPublished', onPub);
|
||||
participant.on('localTrackUnpublished', onUnpub);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
participant.off(RoomEvent.TrackPublished, onPub);
|
||||
participant.off(RoomEvent.TrackUnpublished, onUnpub);
|
||||
participant.off(RoomEvent.TrackSubscribed, onPub);
|
||||
participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
|
||||
participant.off(RoomEvent.TrackMuted, onPub);
|
||||
participant.off(RoomEvent.TrackUnmuted, onPub);
|
||||
participant.off('localTrackPublished', onPub);
|
||||
participant.off('localTrackUnpublished', onUnpub);
|
||||
};
|
||||
}, [participant, source]);
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
||||
@@ -590,13 +473,11 @@ const FocusedStreamView = ({
|
||||
|
||||
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
|
||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf } = useVoice();
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
||||
const screenShareAudioTrackRef = useRef(null);
|
||||
|
||||
// Stream viewing state
|
||||
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
|
||||
const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -629,66 +510,19 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
// Reset watching state when room disconnects
|
||||
// Reset collapsed state when room disconnects
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setWatchingStreamOf(null);
|
||||
setParticipantsCollapsed(false);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
// Manage screen share subscriptions — only subscribe when actively watching
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
|
||||
const manageSubscriptions = () => {
|
||||
for (const p of room.remoteParticipants.values()) {
|
||||
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
|
||||
|
||||
const shouldSubscribe = watchingStreamOf === p.identity;
|
||||
|
||||
if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
|
||||
screenSharePub.setSubscribed(shouldSubscribe);
|
||||
}
|
||||
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
|
||||
screenShareAudioPub.setSubscribed(shouldSubscribe);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
manageSubscriptions();
|
||||
|
||||
// Re-run when new tracks are published (autoSubscribe will subscribe them,
|
||||
// so we need to immediately unsubscribe if not watching)
|
||||
const onTrackChange = () => manageSubscriptions();
|
||||
room.on(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.on(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.off(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
};
|
||||
}, [room, watchingStreamOf]);
|
||||
|
||||
// Derive streaming identities from voiceStates
|
||||
const voiceUsers = voiceStates?.[channelId] || [];
|
||||
const streamingIdentities = new Set(
|
||||
voiceUsers.filter(u => u.isScreenSharing).map(u => u.userId)
|
||||
);
|
||||
|
||||
// Auto-exit if watched participant stops streaming or disconnects
|
||||
useEffect(() => {
|
||||
if (watchingStreamOf === null) return;
|
||||
|
||||
const watchedStillStreaming = streamingIdentities.has(watchingStreamOf);
|
||||
const watchedStillConnected = participants.some(p => p.identity === watchingStreamOf);
|
||||
|
||||
if (!watchedStillStreaming || !watchedStillConnected) {
|
||||
setWatchingStreamOf(null);
|
||||
setParticipantsCollapsed(false);
|
||||
}
|
||||
}, [watchingStreamOf, streamingIdentities, participants]);
|
||||
|
||||
const handleStopWatching = useCallback(() => {
|
||||
setWatchingStreamOf(null);
|
||||
setParticipantsCollapsed(false);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Room, RoomEvent } from 'livekit-client';
|
||||
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { findTrackPubs } from '../utils/streamUtils.jsx';
|
||||
import '@livekit/components-styles';
|
||||
|
||||
import joinSound from '../assets/sounds/join_call.mp3';
|
||||
@@ -45,20 +46,81 @@ export const VoiceProvider = ({ children }) => {
|
||||
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
||||
const isMovingRef = useRef(false);
|
||||
|
||||
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
||||
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
||||
|
||||
const setWatchingStreamOf = useCallback((identity) => {
|
||||
setWatchingStreamOfRaw(identity);
|
||||
}, []);
|
||||
|
||||
// Personal mute state (persisted to localStorage)
|
||||
const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
|
||||
const saved = localStorage.getItem('personallyMutedUsers');
|
||||
return new Set(saved ? JSON.parse(saved) : []);
|
||||
});
|
||||
|
||||
// Per-user volume state: userId → 0-200 (persisted to localStorage)
|
||||
const [userVolumes, setUserVolumes] = useState(() => {
|
||||
const saved = localStorage.getItem('userVolumes');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
});
|
||||
|
||||
const setUserVolume = useCallback((userId, volume) => {
|
||||
setUserVolumes(prev => {
|
||||
const next = { ...prev, [userId]: volume };
|
||||
localStorage.setItem('userVolumes', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
// Apply volume to LiveKit participant
|
||||
const participant = room?.remoteParticipants?.get(userId);
|
||||
if (participant) participant.setVolume(volume / 100);
|
||||
// Sync personal mute state
|
||||
if (volume === 0) {
|
||||
setPersonallyMutedUsers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(userId);
|
||||
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setPersonallyMutedUsers(prev => {
|
||||
if (!prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(userId);
|
||||
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const getUserVolume = useCallback((userId) => {
|
||||
return userVolumes[userId] ?? 100;
|
||||
}, [userVolumes]);
|
||||
|
||||
const togglePersonalMute = (userId) => {
|
||||
setPersonallyMutedUsers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
if (next.has(userId)) {
|
||||
next.delete(userId);
|
||||
// Restore to stored volume (default 100)
|
||||
const vol = userVolumes[userId] ?? 100;
|
||||
const restoreVol = vol === 0 ? 100 : vol;
|
||||
const participant = room?.remoteParticipants?.get(userId);
|
||||
if (participant) participant.setVolume(restoreVol / 100);
|
||||
// Update stored volume if it was 0
|
||||
if (vol === 0) {
|
||||
setUserVolumes(p => {
|
||||
const n = { ...p, [userId]: 100 };
|
||||
localStorage.setItem('userVolumes', JSON.stringify(n));
|
||||
return n;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
next.add(userId);
|
||||
const participant = room?.remoteParticipants?.get(userId);
|
||||
if (participant) participant.setVolume(0);
|
||||
}
|
||||
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
|
||||
const participant = room?.remoteParticipants?.get(userId);
|
||||
if (participant) participant.setVolume(next.has(userId) ? 0 : 1);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -241,18 +303,22 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
}, [voiceStates, room]);
|
||||
|
||||
// Re-apply personal mutes when room or participants change
|
||||
// Re-apply personal mutes/volumes when room or participants change
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
const applyMutes = () => {
|
||||
const applyVolumes = () => {
|
||||
for (const [identity, participant] of room.remoteParticipants) {
|
||||
participant.setVolume(personallyMutedUsers.has(identity) ? 0 : 1);
|
||||
if (personallyMutedUsers.has(identity)) {
|
||||
participant.setVolume(0);
|
||||
} else {
|
||||
participant.setVolume((userVolumes[identity] ?? 100) / 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
applyMutes();
|
||||
room.on(RoomEvent.ParticipantConnected, applyMutes);
|
||||
return () => room.off(RoomEvent.ParticipantConnected, applyMutes);
|
||||
}, [room, personallyMutedUsers]);
|
||||
applyVolumes();
|
||||
room.on(RoomEvent.ParticipantConnected, applyVolumes);
|
||||
return () => room.off(RoomEvent.ParticipantConnected, applyVolumes);
|
||||
}, [room, personallyMutedUsers, userVolumes]);
|
||||
|
||||
// AFK idle polling: move user to AFK channel when idle exceeds timeout
|
||||
useEffect(() => {
|
||||
@@ -282,6 +348,75 @@ export const VoiceProvider = ({ children }) => {
|
||||
return () => clearInterval(interval);
|
||||
}, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);
|
||||
|
||||
// Manage screen share subscriptions — only subscribe when actively watching
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
|
||||
const manageSubscriptions = () => {
|
||||
for (const p of room.remoteParticipants.values()) {
|
||||
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
|
||||
|
||||
const shouldSubscribe = watchingStreamOf === p.identity;
|
||||
|
||||
if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
|
||||
screenSharePub.setSubscribed(shouldSubscribe);
|
||||
}
|
||||
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
|
||||
screenShareAudioPub.setSubscribed(shouldSubscribe);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
manageSubscriptions();
|
||||
|
||||
const onTrackChange = () => manageSubscriptions();
|
||||
room.on(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.on(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, onTrackChange);
|
||||
room.off(RoomEvent.TrackSubscribed, onTrackChange);
|
||||
};
|
||||
}, [room, watchingStreamOf]);
|
||||
|
||||
// Auto-exit if watched participant stops streaming or disconnects
|
||||
useEffect(() => {
|
||||
if (watchingStreamOf === null || !room) return;
|
||||
|
||||
const checkWatched = () => {
|
||||
// Check if participant is still connected
|
||||
const participant = room.remoteParticipants.get(watchingStreamOf)
|
||||
|| (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);
|
||||
|
||||
if (!participant) {
|
||||
setWatchingStreamOfRaw(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if they're still screen sharing
|
||||
const { screenSharePub } = findTrackPubs(participant);
|
||||
if (!screenSharePub) {
|
||||
setWatchingStreamOfRaw(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Also listen for voiceStates changes — covered by the dependency array re-run
|
||||
room.on(RoomEvent.ParticipantDisconnected, checkWatched);
|
||||
room.on(RoomEvent.TrackUnpublished, checkWatched);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantDisconnected, checkWatched);
|
||||
room.off(RoomEvent.TrackUnpublished, checkWatched);
|
||||
};
|
||||
}, [room, watchingStreamOf]);
|
||||
|
||||
// Reset watching state when room disconnects
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setWatchingStreamOfRaw(null);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const disconnectVoice = () => {
|
||||
console.log('User manually disconnected voice');
|
||||
if (room) room.disconnect();
|
||||
@@ -336,10 +471,15 @@ export const VoiceProvider = ({ children }) => {
|
||||
personallyMutedUsers,
|
||||
togglePersonalMute,
|
||||
isPersonallyMuted,
|
||||
userVolumes,
|
||||
setUserVolume,
|
||||
getUserVolume,
|
||||
serverMute,
|
||||
isServerMuted,
|
||||
isInAfkChannel,
|
||||
serverSettings
|
||||
serverSettings,
|
||||
watchingStreamOf,
|
||||
setWatchingStreamOf,
|
||||
}}>
|
||||
{children}
|
||||
{room && (
|
||||
|
||||
@@ -1265,6 +1265,62 @@ body {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CONTEXT MENU VOLUME SLIDER
|
||||
============================================ */
|
||||
.context-menu-volume {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.context-menu-volume-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--header-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.context-menu-volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-menu-volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.context-menu-volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.context-menu-volume-slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MENTION MENU
|
||||
============================================ */
|
||||
|
||||
@@ -4,12 +4,14 @@ import { api } from '../../../../convex/_generated/api';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import ChatArea from '../components/ChatArea';
|
||||
import VoiceStage from '../components/VoiceStage';
|
||||
import FloatingStreamPiP from '../components/FloatingStreamPiP';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import { useToasts } from '../components/Toast';
|
||||
import { PresenceProvider } from '../contexts/PresenceContext';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
|
||||
const Chat = () => {
|
||||
const [view, setView] = useState('server');
|
||||
@@ -59,7 +61,11 @@ const Chat = () => {
|
||||
const storedUsername = localStorage.getItem('username');
|
||||
const storedUserId = localStorage.getItem('userId');
|
||||
if (storedUsername) setUsername(storedUsername);
|
||||
if (storedUserId) setUserId(storedUserId);
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId);
|
||||
const savedView = getUserPref(storedUserId, 'lastView', 'server');
|
||||
setView(savedView);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,11 +91,33 @@ const Chat = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannel || channels.length === 0) return;
|
||||
// Try to restore last active channel
|
||||
if (userId) {
|
||||
const savedChannel = getUserPref(userId, 'lastActiveChannel', null);
|
||||
if (savedChannel && channels.some(c => c._id === savedChannel)) {
|
||||
setActiveChannel(savedChannel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const firstTextChannel = channels.find(c => c.type === 'text');
|
||||
if (firstTextChannel) {
|
||||
setActiveChannel(firstTextChannel._id);
|
||||
}
|
||||
}, [channels, activeChannel]);
|
||||
}, [channels, activeChannel, userId]);
|
||||
|
||||
// Persist active channel
|
||||
useEffect(() => {
|
||||
if (activeChannel && userId) {
|
||||
setUserPref(userId, 'lastActiveChannel', activeChannel);
|
||||
}
|
||||
}, [activeChannel, userId]);
|
||||
|
||||
// Persist view mode
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setUserPref(userId, 'lastView', view);
|
||||
}
|
||||
}, [view, userId]);
|
||||
|
||||
const openDM = useCallback(async (targetUserId, targetUsername) => {
|
||||
const uid = localStorage.getItem('userId');
|
||||
@@ -145,12 +173,23 @@ const Chat = () => {
|
||||
}, []);
|
||||
|
||||
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
||||
const { room, voiceStates } = useVoice();
|
||||
const { room, voiceStates, activeChannelId: voiceActiveChannelId, watchingStreamOf } = useVoice();
|
||||
|
||||
const isDMView = view === 'me' && activeDMChannel;
|
||||
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
|
||||
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
|
||||
|
||||
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
|
||||
const isViewingVoiceStage = view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId;
|
||||
const showPiP = watchingStreamOf !== null && !isViewingVoiceStage;
|
||||
|
||||
const handleGoBackToStream = useCallback(() => {
|
||||
if (voiceActiveChannelId) {
|
||||
setActiveChannel(voiceActiveChannelId);
|
||||
setView('server');
|
||||
}
|
||||
}, [voiceActiveChannelId]);
|
||||
|
||||
function renderMainContent() {
|
||||
if (view === 'me') {
|
||||
if (activeDMChannel) {
|
||||
@@ -261,6 +300,7 @@ const Chat = () => {
|
||||
userId={userId}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
|
||||
118
Frontend/Electron/src/utils/streamUtils.jsx
Normal file
118
Frontend/Electron/src/utils/streamUtils.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
|
||||
export const VideoRenderer = ({ track, style }) => {
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (el && track) {
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}
|
||||
}, [track]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black', ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function findTrackPubs(participant) {
|
||||
let cameraPub = null;
|
||||
let screenSharePub = null;
|
||||
let screenShareAudioPub = null;
|
||||
|
||||
const trackMap = participant.tracks || participant.trackPublications;
|
||||
if (trackMap) {
|
||||
for (const pub of trackMap.values()) {
|
||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
|
||||
|
||||
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
|
||||
screenShareAudioPub = pub;
|
||||
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
|
||||
screenSharePub = pub;
|
||||
} else if (source === 'camera' || name.includes('camera')) {
|
||||
cameraPub = pub;
|
||||
} else if (pub.kind === 'video' && !screenSharePub) {
|
||||
screenSharePub = pub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for older SDKs
|
||||
if ((!screenSharePub && !cameraPub) && participant.getTracks) {
|
||||
try {
|
||||
for (const pub of participant.getTracks()) {
|
||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
|
||||
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
|
||||
screenShareAudioPub = pub;
|
||||
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
|
||||
screenSharePub = pub;
|
||||
} else if (source === 'camera' || name.includes('camera')) {
|
||||
cameraPub = pub;
|
||||
} else if (pub.kind === 'video' && !screenSharePub) {
|
||||
screenSharePub = pub;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return { cameraPub, screenSharePub, screenShareAudioPub };
|
||||
}
|
||||
|
||||
export function useParticipantTrack(participant, source) {
|
||||
const [track, setTrack] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!participant) { setTrack(null); return; }
|
||||
|
||||
const resolve = () => {
|
||||
const { cameraPub, screenSharePub } = findTrackPubs(participant);
|
||||
const pub = source === 'camera' ? cameraPub : screenSharePub;
|
||||
|
||||
if (pub && !pub.isSubscribed && source === 'camera') {
|
||||
pub.setSubscribed(true);
|
||||
}
|
||||
|
||||
setTrack(pub?.track || null);
|
||||
};
|
||||
|
||||
resolve();
|
||||
const interval = setInterval(resolve, 1000);
|
||||
const timeout = setTimeout(() => clearInterval(interval), 10000);
|
||||
|
||||
const onPub = () => resolve();
|
||||
const onUnpub = () => resolve();
|
||||
|
||||
participant.on(RoomEvent.TrackPublished, onPub);
|
||||
participant.on(RoomEvent.TrackUnpublished, onUnpub);
|
||||
participant.on(RoomEvent.TrackSubscribed, onPub);
|
||||
participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
|
||||
participant.on(RoomEvent.TrackMuted, onPub);
|
||||
participant.on(RoomEvent.TrackUnmuted, onPub);
|
||||
participant.on('localTrackPublished', onPub);
|
||||
participant.on('localTrackUnpublished', onUnpub);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
participant.off(RoomEvent.TrackPublished, onPub);
|
||||
participant.off(RoomEvent.TrackUnpublished, onUnpub);
|
||||
participant.off(RoomEvent.TrackSubscribed, onPub);
|
||||
participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
|
||||
participant.off(RoomEvent.TrackMuted, onPub);
|
||||
participant.off(RoomEvent.TrackUnmuted, onPub);
|
||||
participant.off('localTrackPublished', onPub);
|
||||
participant.off('localTrackUnpublished', onUnpub);
|
||||
};
|
||||
}, [participant, source]);
|
||||
|
||||
return track;
|
||||
}
|
||||
23
Frontend/Electron/src/utils/userPreferences.js
Normal file
23
Frontend/Electron/src/utils/userPreferences.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export function getUserPref(userId, key, defaultValue) {
|
||||
if (!userId) return defaultValue;
|
||||
try {
|
||||
const raw = localStorage.getItem(`userPrefs_${userId}`);
|
||||
if (!raw) return defaultValue;
|
||||
const prefs = JSON.parse(raw);
|
||||
return prefs[key] !== undefined ? prefs[key] : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserPref(userId, key, value) {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(`userPrefs_${userId}`);
|
||||
const prefs = raw ? JSON.parse(raw) : {};
|
||||
prefs[key] = value;
|
||||
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Silently fail on corrupt data or full storage
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user