Merge branch 'voice_test' into feat/VersionsExplorer
This commit is contained in:
@ -30,6 +30,7 @@ import FakePaymentPage from './pages/FakePaymentPage';
|
|||||||
import { TrayBridge } from './utils/TrayBridge';
|
import { TrayBridge } from './utils/TrayBridge';
|
||||||
import { API_BASE_URL } from './api';
|
import { API_BASE_URL } from './api';
|
||||||
import { PromoRedeem } from './pages/PromoRedeem';
|
import { PromoRedeem } from './pages/PromoRedeem';
|
||||||
|
import VoicePage from './pages/VoicePage';
|
||||||
|
|
||||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
@ -376,6 +377,14 @@ const AppLayout = () => {
|
|||||||
</AuthCheck>
|
</AuthCheck>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/voice"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<VoicePage />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/shop"
|
path="/shop"
|
||||||
element={
|
element={
|
||||||
|
|||||||
62
src/renderer/api/voiceRooms.ts
Normal file
62
src/renderer/api/voiceRooms.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
const API_BASE = 'https://minecraft.api.popa-popa.ru';
|
||||||
|
|
||||||
|
export type ApiRoom = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
owner: string;
|
||||||
|
max_users: number;
|
||||||
|
usernames: string[];
|
||||||
|
users: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRooms(): Promise<ApiRoom[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/voice/rooms`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoomDetails(roomId: string): Promise<RoomDetails> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/voice/rooms/${roomId}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPublicRoom(
|
||||||
|
name: string,
|
||||||
|
owner: string,
|
||||||
|
isPublic: boolean,
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/voice/rooms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
public: isPublic,
|
||||||
|
owner,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinPrivateRoom(code: string): Promise<ApiRoom> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/voice/rooms/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ export default function PageHeader() {
|
|||||||
path === '/inventory' ||
|
path === '/inventory' ||
|
||||||
path === '/fakepaymentpage' ||
|
path === '/fakepaymentpage' ||
|
||||||
path === '/promocode' ||
|
path === '/promocode' ||
|
||||||
|
path === '/voice' ||
|
||||||
path.startsWith('/launch')
|
path.startsWith('/launch')
|
||||||
) {
|
) {
|
||||||
return { title: '', subtitle: '', hidden: true };
|
return { title: '', subtitle: '', hidden: true };
|
||||||
|
|||||||
@ -25,7 +25,10 @@ import InventoryIcon from '@mui/icons-material/Inventory';
|
|||||||
import { RiCoupon3Fill } from 'react-icons/ri';
|
import { RiCoupon3Fill } from 'react-icons/ri';
|
||||||
|
|
||||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
import {
|
||||||
|
isNotificationsEnabled,
|
||||||
|
getNotifPositionFromSettings,
|
||||||
|
} from '../utils/notifications';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -145,6 +148,11 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
match: (p) => p.startsWith('/marketplace'),
|
match: (p) => p.startsWith('/marketplace'),
|
||||||
to: '/marketplace',
|
to: '/marketplace',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 4,
|
||||||
|
match: (p) => p.startsWith('/voice'),
|
||||||
|
to: '/voice',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedTab =
|
const selectedTab =
|
||||||
@ -318,7 +326,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
const ctx = JSON.parse(raw);
|
const ctx = JSON.parse(raw);
|
||||||
|
|
||||||
const savedConfig = JSON.parse(
|
const savedConfig = JSON.parse(
|
||||||
localStorage.getItem('launcher_config') || '{}',
|
localStorage.getItem('launcher_config') || '{}',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!savedConfig.accessToken) {
|
if (!savedConfig.accessToken) {
|
||||||
@ -536,7 +544,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
marginRight: '1vw',
|
marginRight: '1vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lastVersion &&
|
{lastVersion && (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
title={getLastLaunchLabel(lastVersion)}
|
title={getLastLaunchLabel(lastVersion)}
|
||||||
arrow
|
arrow
|
||||||
@ -598,8 +606,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: '0.15vw',
|
height: '0.15vw',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
background:
|
// background:
|
||||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -608,7 +616,70 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<span style={{ fontSize: '1vw' }}>⚡</span>
|
<span style={{ fontSize: '1vw' }}>⚡</span>
|
||||||
</Button>
|
</Button>
|
||||||
</CustomTooltip>
|
</CustomTooltip>
|
||||||
}
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/voice')}
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
sx={{
|
||||||
|
minWidth: 'unset',
|
||||||
|
width: '3vw',
|
||||||
|
height: '3vw',
|
||||||
|
borderRadius: '3vw',
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
overflow: 'hidden',
|
||||||
|
|
||||||
|
px: '0.8vw',
|
||||||
|
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||||||
|
color: 'white',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
|
||||||
|
'& .quick-text': {
|
||||||
|
opacity: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
marginRight: '0.6vw',
|
||||||
|
fontSize: '0.9vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
transform: 'translateX(10px)',
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&:hover': {
|
||||||
|
width: '16.5vw',
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
|
||||||
|
'& .quick-text': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0%',
|
||||||
|
right: '0%',
|
||||||
|
bottom: 0,
|
||||||
|
height: '0.15vw',
|
||||||
|
borderRadius: '999px',
|
||||||
|
// background:
|
||||||
|
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="quick-text">Голосовой чат</span>
|
||||||
|
<span style={{ fontSize: '1vw' }}>🎙️</span>
|
||||||
|
</Button>
|
||||||
{!isLoginPage && !isRegistrationPage && username && (
|
{!isLoginPage && !isRegistrationPage && username && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
||||||
<HeadAvatar
|
<HeadAvatar
|
||||||
|
|||||||
112
src/renderer/components/Voice/CreateRoomDialog.tsx
Normal file
112
src/renderer/components/Voice/CreateRoomDialog.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
FormControlLabel,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function CreateRoomDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (data: { name: string; isPublic: boolean }) => Promise<{
|
||||||
|
invite_code?: string | null;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await onCreate({
|
||||||
|
name,
|
||||||
|
isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPublic && res?.invite_code) {
|
||||||
|
setInviteCode(res.invite_code);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Создать комнату</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{!inviteCode ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label="Название комнаты"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={(e) => setIsPublic(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={isPublic ? 'Публичная' : 'Приватная'}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ mb: 1 }}>Код для входа в комнату:</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={inviteCode}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(inviteCode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скопировать код
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Закрыть</Button>
|
||||||
|
|
||||||
|
{!inviteCode && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!name || loading}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/renderer/components/Voice/JoinByCodeDialog.tsx
Normal file
58
src/renderer/components/Voice/JoinByCodeDialog.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function JoinByCodeDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onJoin,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onJoin: (code: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await onJoin(code);
|
||||||
|
setLoading(false);
|
||||||
|
setCode('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Войти по коду</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label="Invite-код"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={!code || loading}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/renderer/components/Voice/RoomsPanel.tsx
Normal file
115
src/renderer/components/Voice/RoomsPanel.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { Box, Typography, Button } from '@mui/material';
|
||||||
|
import { glassBox, GRADIENT } from '../../theme/voiceStyles';
|
||||||
|
import type { RoomInfo } from '../../types/rooms';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rooms: RoomInfo[];
|
||||||
|
currentRoomId: string | null;
|
||||||
|
onJoin: (roomId: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onJoinByCode: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomsPanel({
|
||||||
|
rooms,
|
||||||
|
currentRoomId,
|
||||||
|
onJoin,
|
||||||
|
onCreate,
|
||||||
|
onJoinByCode,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 300,
|
||||||
|
p: '1.2vw',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1vw',
|
||||||
|
borderRadius: '1.5vw',
|
||||||
|
...glassBox,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.4vw',
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Голосовые комнаты
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', pr: '0.3vw' }}>
|
||||||
|
{rooms.map((room) => {
|
||||||
|
const active = room.id === currentRoomId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={room.id}
|
||||||
|
onClick={() => onJoin(room.id)}
|
||||||
|
sx={{
|
||||||
|
p: '0.9vw',
|
||||||
|
mb: '0.6vw',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
background: active ? GRADIENT : 'rgba(255,255,255,0.04)',
|
||||||
|
color: active ? '#fff' : 'rgba(255,255,255,0.9)',
|
||||||
|
'&:hover': {
|
||||||
|
//transform: 'scale(1.01)',
|
||||||
|
background: active ? GRADIENT : 'rgba(255,255,255,0.07)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 800 }}>{room.name}</Typography>
|
||||||
|
|
||||||
|
{room.users.length > 0 && (
|
||||||
|
<Box sx={{ mt: '0.4vw', pl: '0.8vw' }}>
|
||||||
|
{room.users.map((u) => (
|
||||||
|
<Typography
|
||||||
|
key={u}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.85vw',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
• {u}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onCreate}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
py: '0.8vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
background: GRADIENT,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Создать комнату
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onJoinByCode}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
py: '0.8vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти по коду
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/renderer/components/Voice/VoicePanel.tsx
Normal file
131
src/renderer/components/Voice/VoicePanel.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
import { useVoiceRoom } from '../../realtime/voice/useVoiceRoom';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getVoiceState, subscribeVoice } from '../../realtime/voice/voiceStore';
|
||||||
|
import { glassBox, GRADIENT } from '../../theme/voiceStyles';
|
||||||
|
import { HeadAvatar } from '../HeadAvatar';
|
||||||
|
import { API_BASE_URL } from '../../api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
roomId: string;
|
||||||
|
voice: {
|
||||||
|
disconnect: () => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
};
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VoicePanel({ roomId, voice, roomName }: Props) {
|
||||||
|
const [state, setState] = useState(getVoiceState());
|
||||||
|
const [skinMap, setSkinMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSkins = async () => {
|
||||||
|
const missing = state.participants.filter((u) => !skinMap[u]);
|
||||||
|
if (!missing.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ⚠️ ВАЖНО: полный URL до API
|
||||||
|
const res = await fetch(API_BASE_URL + '/users');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const user of data.users) {
|
||||||
|
if (missing.includes(user.username) && user.skin_url) {
|
||||||
|
map[user.username] = user.skin_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(map).length) {
|
||||||
|
setSkinMap((prev) => ({ ...prev, ...map }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Не удалось загрузить скины для голосового чата', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSkins();
|
||||||
|
}, [state.participants]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeVoice(() => {
|
||||||
|
setState({ ...getVoiceState() });
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('participants:', state.participants);
|
||||||
|
console.log('skinMap:', skinMap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
ml: '1.5vw',
|
||||||
|
p: '2vw',
|
||||||
|
borderRadius: '1.5vw',
|
||||||
|
...glassBox,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '2vw',
|
||||||
|
mb: '1.2vw',
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: '1vw', mb: '2vw' }}>
|
||||||
|
<Button
|
||||||
|
onClick={voice.toggleMute}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
px: '2vw',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.muted ? 'Вкл. микрофон' : 'Выкл. микрофон'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={voice.disconnect}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
px: '2vw',
|
||||||
|
background: 'rgba(255,60,60,0.25)',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography sx={{ mb: '0.6vw', opacity: 0.7 }}>Участники:</Typography>
|
||||||
|
|
||||||
|
{state.participants.map((u) => (
|
||||||
|
<Box
|
||||||
|
key={u}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.6vw',
|
||||||
|
mb: '0.3vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeadAvatar skinUrl={skinMap[u]} size={28} />
|
||||||
|
|
||||||
|
<Typography>{u}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/renderer/mappers/roomMapper.ts
Normal file
11
src/renderer/mappers/roomMapper.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type { ApiRoom } from '../api/voiceRooms';
|
||||||
|
import type { RoomInfo } from '../types/rooms';
|
||||||
|
|
||||||
|
export function mapApiRoomToUI(room: ApiRoom): RoomInfo {
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
public: room.public,
|
||||||
|
users: room.usernames ?? [], // ⬅️ ВАЖНО: список пользователей приходит ТОЛЬКО по WS
|
||||||
|
};
|
||||||
|
}
|
||||||
163
src/renderer/pages/VoicePage.tsx
Normal file
163
src/renderer/pages/VoicePage.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { RoomsPanel } from '../components/Voice/RoomsPanel';
|
||||||
|
import { VoicePanel } from '../components/Voice/VoicePanel';
|
||||||
|
import {
|
||||||
|
fetchRooms,
|
||||||
|
createPublicRoom,
|
||||||
|
joinPrivateRoom,
|
||||||
|
} from '../api/voiceRooms';
|
||||||
|
import { mapApiRoomToUI } from '../mappers/roomMapper';
|
||||||
|
import type { RoomInfo } from '../types/rooms';
|
||||||
|
import { CreateRoomDialog } from '../components/Voice/CreateRoomDialog';
|
||||||
|
import { JoinByCodeDialog } from '../components/Voice/JoinByCodeDialog';
|
||||||
|
import { useVoiceRoom } from '../realtime/voice/useVoiceRoom';
|
||||||
|
|
||||||
|
export default function VoicePage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||||
|
const [currentRoomId, setCurrentRoomId] = useState<string | null>(null);
|
||||||
|
const [currentRoom, setCurrentRoom] = useState<RoomInfo | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [joinOpen, setJoinOpen] = useState(false);
|
||||||
|
|
||||||
|
const voice = useVoiceRoom(username);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// --- username ---
|
||||||
|
useEffect(() => {
|
||||||
|
const savedConfig = localStorage.getItem('launcher_config');
|
||||||
|
if (savedConfig) {
|
||||||
|
const config = JSON.parse(savedConfig);
|
||||||
|
if (config.username) {
|
||||||
|
setUsername(config.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- HTTP: initial rooms list ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
fetchRooms()
|
||||||
|
.then((apiRooms) => {
|
||||||
|
setRooms(apiRooms.map(mapApiRoomToUI));
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
// --- open dialog ---
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- WS: users inside rooms ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`wss://minecraft.api.popa-popa.ru/ws/rooms?username=${username}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
|
||||||
|
if (msg.type !== 'rooms') return;
|
||||||
|
|
||||||
|
setRooms((prev) => {
|
||||||
|
const map = new Map(prev.map((r) => [r.id, r]));
|
||||||
|
|
||||||
|
for (const updated of msg.rooms) {
|
||||||
|
const existing = map.get(updated.id);
|
||||||
|
if (!existing) continue;
|
||||||
|
|
||||||
|
map.set(updated.id, {
|
||||||
|
...existing,
|
||||||
|
users: updated.users ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
const joinRoom = (roomId: string) => {
|
||||||
|
const room = rooms.find((r) => r.id === roomId);
|
||||||
|
if (!room) return;
|
||||||
|
setCurrentRoomId(roomId);
|
||||||
|
setCurrentRoom(room);
|
||||||
|
voice.connect(roomId); // 🔥 АВТОПОДКЛЮЧЕНИЕ
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRoom = async ({
|
||||||
|
name,
|
||||||
|
isPublic,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
}) => {
|
||||||
|
const apiRoom = await createPublicRoom(name, username, isPublic);
|
||||||
|
|
||||||
|
const room: RoomInfo = mapApiRoomToUI(apiRoom);
|
||||||
|
|
||||||
|
setRooms((prev) => [room, ...prev]);
|
||||||
|
|
||||||
|
setCurrentRoomId(apiRoom.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
invite_code: apiRoom.invite_code ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinByCode = async (code: string) => {
|
||||||
|
const room = await joinPrivateRoom(code);
|
||||||
|
setCurrentRoomId(room.id);
|
||||||
|
setCurrentRoom({
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
public: false,
|
||||||
|
users: [],
|
||||||
|
maxUsers: room.max_users,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', width: '98%', height: '90%', marginTop: '9vh' }}
|
||||||
|
>
|
||||||
|
<RoomsPanel
|
||||||
|
rooms={rooms}
|
||||||
|
currentRoomId={currentRoomId}
|
||||||
|
onJoin={joinRoom}
|
||||||
|
onCreate={() => setCreateOpen(true)}
|
||||||
|
onJoinByCode={() => setJoinOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateRoomDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreate={handleCreateRoom}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JoinByCodeDialog
|
||||||
|
open={joinOpen}
|
||||||
|
onClose={() => setJoinOpen(false)}
|
||||||
|
onJoin={handleJoinByCode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentRoomId && (
|
||||||
|
<VoicePanel
|
||||||
|
roomId={currentRoomId}
|
||||||
|
voice={voice}
|
||||||
|
roomName={currentRoom.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/renderer/realtime/voice/rtcConfig.ts
Normal file
3
src/renderer/realtime/voice/rtcConfig.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const rtcConfig: RTCConfiguration = {
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
|
};
|
||||||
13
src/renderer/realtime/voice/types.ts
Normal file
13
src/renderer/realtime/voice/types.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export type WSMessage =
|
||||||
|
| { type: 'users'; users: string[] }
|
||||||
|
| { type: 'join'; user: string }
|
||||||
|
| { type: 'leave'; user: string }
|
||||||
|
| {
|
||||||
|
type: 'signal';
|
||||||
|
from: string;
|
||||||
|
data: {
|
||||||
|
type: 'offer' | 'answer' | 'ice';
|
||||||
|
sdp?: RTCSessionDescriptionInit;
|
||||||
|
candidate?: RTCIceCandidateInit;
|
||||||
|
};
|
||||||
|
};
|
||||||
298
src/renderer/realtime/voice/useVoiceRoom.ts
Normal file
298
src/renderer/realtime/voice/useVoiceRoom.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { rtcConfig } from './rtcConfig';
|
||||||
|
import type { WSMessage } from './types';
|
||||||
|
import { setVoiceState, getVoiceState } from './voiceStore';
|
||||||
|
|
||||||
|
type PeerMap = Map<string, RTCPeerConnection>;
|
||||||
|
|
||||||
|
export function useVoiceRoom(username: string) {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const peersRef = useRef<PeerMap>(new Map());
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
const currentRoomIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const reconnectTimeout = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// const [connected, setConnected] = useState(false);
|
||||||
|
// const [participants, setParticipants] = useState<string[]>([]);
|
||||||
|
// const [muted, setMuted] = useState(false);
|
||||||
|
|
||||||
|
const pendingIceRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
||||||
|
|
||||||
|
// --- connect ---
|
||||||
|
const connect = useCallback(
|
||||||
|
async (roomId: string) => {
|
||||||
|
if (wsRef.current) return;
|
||||||
|
|
||||||
|
currentRoomIdRef.current = roomId;
|
||||||
|
|
||||||
|
// 1. микрофон
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
// 2. websocket
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`wss://minecraft.api.popa-popa.ru/ws/voice?room_id=${roomId}&username=${username}`,
|
||||||
|
);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setVoiceState({
|
||||||
|
connected: true,
|
||||||
|
shouldBeConnected: true,
|
||||||
|
participants: [username],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
setVoiceState({ connected: false });
|
||||||
|
|
||||||
|
if (getVoiceState().shouldBeConnected) {
|
||||||
|
reconnectTimeout.current = window.setTimeout(() => {
|
||||||
|
const lastRoomId = currentRoomIdRef.current;
|
||||||
|
if (lastRoomId) {
|
||||||
|
connect(lastRoomId);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ws.onclose = () => {
|
||||||
|
// cleanup();
|
||||||
|
// setConnected(false);
|
||||||
|
// };
|
||||||
|
|
||||||
|
ws.onmessage = async (ev) => {
|
||||||
|
const msg: WSMessage = JSON.parse(ev.data);
|
||||||
|
|
||||||
|
if (msg.type === 'join' && msg.user !== username) {
|
||||||
|
await createPeer(msg.user, false);
|
||||||
|
|
||||||
|
const { participants } = getVoiceState();
|
||||||
|
if (!participants.includes(msg.user)) {
|
||||||
|
setVoiceState({
|
||||||
|
participants: [...participants, msg.user],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'leave') {
|
||||||
|
removePeer(msg.user);
|
||||||
|
|
||||||
|
setVoiceState({
|
||||||
|
participants: getVoiceState().participants.filter(
|
||||||
|
(u) => u !== msg.user,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'signal') {
|
||||||
|
await handleSignal(msg.from, msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'users') {
|
||||||
|
const current = getVoiceState().participants;
|
||||||
|
const next = msg.users;
|
||||||
|
|
||||||
|
// 1. удаляем ушедших
|
||||||
|
for (const user of current) {
|
||||||
|
if (!next.includes(user)) {
|
||||||
|
removePeer(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. создаём peer для новых
|
||||||
|
for (const user of next) {
|
||||||
|
if (user !== username && !peersRef.current.has(user)) {
|
||||||
|
await createPeer(user, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. обновляем store
|
||||||
|
setVoiceState({
|
||||||
|
participants: next,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[username],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- create peer ---
|
||||||
|
const createPeer = async (user: string, polite: boolean) => {
|
||||||
|
if (peersRef.current.has(user)) return;
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection(rtcConfig);
|
||||||
|
peersRef.current.set(user, pc);
|
||||||
|
|
||||||
|
streamRef.current
|
||||||
|
?.getTracks()
|
||||||
|
.forEach((t) => pc.addTrack(t, streamRef.current!));
|
||||||
|
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate) {
|
||||||
|
wsRef.current?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'signal',
|
||||||
|
to: user,
|
||||||
|
data: { type: 'ice', candidate: e.candidate },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
audio.srcObject = e.streams[0];
|
||||||
|
audio.autoplay = true;
|
||||||
|
audio.setAttribute('data-user', user);
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!polite) {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
wsRef.current?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'signal',
|
||||||
|
to: user,
|
||||||
|
data: { type: 'offer', sdp: offer },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePeer = (user: string) => {
|
||||||
|
const pc = peersRef.current.get(user);
|
||||||
|
if (!pc) return;
|
||||||
|
|
||||||
|
pc.close();
|
||||||
|
peersRef.current.delete(user);
|
||||||
|
|
||||||
|
pendingIceRef.current.delete(user);
|
||||||
|
|
||||||
|
// удаляем audio элемент
|
||||||
|
const audio = document.querySelector(
|
||||||
|
`audio[data-user="${user}"]`,
|
||||||
|
) as HTMLAudioElement | null;
|
||||||
|
|
||||||
|
audio?.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- signaling ---
|
||||||
|
const handleSignal = async (from: string, data: any) => {
|
||||||
|
let pc = peersRef.current.get(from);
|
||||||
|
if (!pc) {
|
||||||
|
await createPeer(from, true);
|
||||||
|
pc = peersRef.current.get(from)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'offer') {
|
||||||
|
if (pc.signalingState !== 'stable') {
|
||||||
|
console.warn('Skip offer, state:', pc.signalingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(data.sdp);
|
||||||
|
|
||||||
|
// 🔥 применяем накопленные ICE
|
||||||
|
const queued = pendingIceRef.current.get(from);
|
||||||
|
if (queued) {
|
||||||
|
for (const c of queued) {
|
||||||
|
await pc.addIceCandidate(c);
|
||||||
|
}
|
||||||
|
pendingIceRef.current.delete(from);
|
||||||
|
}
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
wsRef.current?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'signal',
|
||||||
|
to: from,
|
||||||
|
data: { type: 'answer', sdp: answer },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'answer') {
|
||||||
|
if (pc.signalingState === 'have-local-offer') {
|
||||||
|
await pc.setRemoteDescription(data.sdp);
|
||||||
|
|
||||||
|
const queued = pendingIceRef.current.get(from);
|
||||||
|
if (queued) {
|
||||||
|
for (const c of queued) {
|
||||||
|
await pc.addIceCandidate(c);
|
||||||
|
}
|
||||||
|
pendingIceRef.current.delete(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'ice') {
|
||||||
|
if (pc.remoteDescription) {
|
||||||
|
await pc.addIceCandidate(data.candidate);
|
||||||
|
} else {
|
||||||
|
// ⏳ remoteDescription ещё нет — сохраняем
|
||||||
|
const queue = pendingIceRef.current.get(from) ?? [];
|
||||||
|
queue.push(data.candidate);
|
||||||
|
pendingIceRef.current.set(from, queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- mute ---
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!streamRef.current) return;
|
||||||
|
|
||||||
|
const enabled = !getVoiceState().muted;
|
||||||
|
|
||||||
|
streamRef.current.getAudioTracks().forEach((t) => (t.enabled = !enabled));
|
||||||
|
|
||||||
|
setVoiceState({ muted: enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- cleanup ---
|
||||||
|
const cleanup = () => {
|
||||||
|
peersRef.current.forEach((pc) => pc.close());
|
||||||
|
peersRef.current.clear();
|
||||||
|
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
|
||||||
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
if (reconnectTimeout.current) {
|
||||||
|
clearTimeout(reconnectTimeout.current);
|
||||||
|
reconnectTimeout.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('audio[data-user]').forEach((a) => a.remove());
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
setVoiceState({
|
||||||
|
connected: false,
|
||||||
|
shouldBeConnected: false,
|
||||||
|
participants: [],
|
||||||
|
muted: false,
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
toggleMute,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
src/renderer/realtime/voice/voiceStore.ts
Normal file
32
src/renderer/realtime/voice/voiceStore.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
type VoiceState = {
|
||||||
|
connected: boolean;
|
||||||
|
shouldBeConnected: boolean;
|
||||||
|
participants: string[];
|
||||||
|
muted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: VoiceState = {
|
||||||
|
connected: false,
|
||||||
|
shouldBeConnected: false,
|
||||||
|
participants: [],
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getVoiceState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVoiceState(patch: Partial<VoiceState>) {
|
||||||
|
Object.assign(state, patch);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeVoice(cb: () => void): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/renderer/theme/voiceStyles.ts
Normal file
12
src/renderer/theme/voiceStyles.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const GRADIENT =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
export const glassBox = {
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.12), transparent 55%),' +
|
||||||
|
'radial-gradient(circle at 90% 20%, rgba(233,64,205,0.10), transparent 55%),' +
|
||||||
|
'rgba(10,10,20,0.86)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
};
|
||||||
17
src/renderer/types/rooms.ts
Normal file
17
src/renderer/types/rooms.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export type RoomInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
users: string[]; // usernames
|
||||||
|
maxUsers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoomDetails = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
max_users: number;
|
||||||
|
users: number;
|
||||||
|
usernames: string[];
|
||||||
|
public: boolean;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user