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);
|
||||
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
|
||||
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(next.has(userId) ? 0 : 1);
|
||||
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]));
|
||||
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
|
||||
}
|
||||
}
|
||||
5
TODO.md
5
TODO.md
@@ -29,3 +29,8 @@
|
||||
# Future
|
||||
|
||||
- Allow users to add custom join sounds.
|
||||
|
||||
|
||||
|
||||
|
||||
How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc.
|
||||
Reference in New Issue
Block a user