voice v2
This commit is contained in:
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 === '/fakepaymentpage' ||
|
||||
path === '/promocode' ||
|
||||
path === '/voice' ||
|
||||
path.startsWith('/launch')
|
||||
) {
|
||||
return { title: '', subtitle: '', hidden: true };
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
84
src/renderer/components/Voice/VoicePanel.tsx
Normal file
84
src/renderer/components/Voice/VoicePanel.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
roomId: string;
|
||||
voice: {
|
||||
disconnect: () => void;
|
||||
toggleMute: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function VoicePanel({ roomId, voice }: Props) {
|
||||
const [state, setState] = useState(getVoiceState());
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeVoice(() => {
|
||||
setState({ ...getVoiceState() });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{roomId}
|
||||
</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) => (
|
||||
<Typography key={u}>• {u}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import { useVoiceRoom } from '../realtime/voice/useVoiceRoom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getVoiceState, subscribeVoice } from '../realtime/voice/voiceStore';
|
||||
|
||||
export function VoicePanel({
|
||||
roomId,
|
||||
username,
|
||||
}: {
|
||||
roomId: string;
|
||||
username: string;
|
||||
}) {
|
||||
const [voice, setVoice] = useState(getVoiceState());
|
||||
const { connect, disconnect, toggleMute } = useVoiceRoom(roomId, username);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeVoice(() => {
|
||||
setVoice({ ...getVoiceState() });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button disabled={voice.connected} onClick={connect}>
|
||||
Войти
|
||||
</button>
|
||||
|
||||
<button onClick={toggleMute}>
|
||||
{voice.muted ? 'Включить микрофон' : 'Выключить микрофон'}
|
||||
</button>
|
||||
|
||||
<button onClick={disconnect}>Выйти</button>
|
||||
|
||||
<ul>
|
||||
{voice.participants.map((u) => (
|
||||
<li key={u}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,23 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VoicePanel } from '../components/VoicePanel';
|
||||
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';
|
||||
import type { RoomDetails } from '../types/rooms';
|
||||
|
||||
export default function VoicePage() {
|
||||
const [username, setUsername] = useState<string>('');
|
||||
const [roomDetails, setRoomDetails] = useState<RoomDetails | null>(null);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||
const [currentRoomId, setCurrentRoomId] = useState<string | 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.uuid && config.username) {
|
||||
if (config.username) {
|
||||
setUsername(config.username);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!username) {
|
||||
console.warn('Voice: username is empty');
|
||||
return;
|
||||
// --- 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 <VoicePanel roomId="test-room" username={username} />;
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [username]);
|
||||
|
||||
// --- handlers ---
|
||||
const joinRoom = (roomId: string) => {
|
||||
setCurrentRoomId(roomId);
|
||||
voice.connect(roomId); // 🔥 АВТОПОДКЛЮЧЕНИЕ
|
||||
};
|
||||
|
||||
const handleCreateRoom = async ({
|
||||
name,
|
||||
isPublic,
|
||||
}: {
|
||||
name: string;
|
||||
isPublic: boolean;
|
||||
}) => {
|
||||
const apiRoom = await createPublicRoom(name, username, isPublic);
|
||||
|
||||
setCurrentRoomId(apiRoom.id);
|
||||
|
||||
return {
|
||||
invite_code: apiRoom.invite_code ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const handleJoinByCode = async (code: string) => {
|
||||
const room = await joinPrivateRoom(code);
|
||||
setCurrentRoomId(room.id);
|
||||
};
|
||||
|
||||
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} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export type WSMessage =
|
||||
| { type: 'users'; users: string[] }
|
||||
| { type: 'join'; user: string }
|
||||
| { type: 'leave'; user: string }
|
||||
| {
|
||||
|
||||
@ -5,11 +5,15 @@ import { setVoiceState, getVoiceState } from './voiceStore';
|
||||
|
||||
type PeerMap = Map<string, RTCPeerConnection>;
|
||||
|
||||
export function useVoiceRoom(roomId: string, username: string) {
|
||||
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);
|
||||
@ -17,9 +21,12 @@ export function useVoiceRoom(roomId: string, username: string) {
|
||||
const pendingIceRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
||||
|
||||
// --- connect ---
|
||||
const connect = useCallback(async () => {
|
||||
const connect = useCallback(
|
||||
async (roomId: string) => {
|
||||
if (wsRef.current) return;
|
||||
|
||||
currentRoomIdRef.current = roomId;
|
||||
|
||||
// 1. микрофон
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
@ -39,17 +46,24 @@ export function useVoiceRoom(roomId: string, username: string) {
|
||||
ws.onopen = () => {
|
||||
setVoiceState({
|
||||
connected: true,
|
||||
shouldBeConnected: true,
|
||||
participants: [username],
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setVoiceState({
|
||||
connected: false,
|
||||
participants: [],
|
||||
muted: false,
|
||||
});
|
||||
cleanup();
|
||||
|
||||
setVoiceState({ connected: false });
|
||||
|
||||
if (getVoiceState().shouldBeConnected) {
|
||||
reconnectTimeout.current = window.setTimeout(() => {
|
||||
const lastRoomId = currentRoomIdRef.current;
|
||||
if (lastRoomId) {
|
||||
connect(lastRoomId);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
// ws.onclose = () => {
|
||||
@ -84,8 +98,34 @@ export function useVoiceRoom(roomId: string, username: string) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [roomId, username]);
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
// --- create peer ---
|
||||
const createPeer = async (user: string, polite: boolean) => {
|
||||
@ -232,16 +272,22 @@ export function useVoiceRoom(roomId: string, username: string) {
|
||||
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 = () => {
|
||||
cleanup();
|
||||
setVoiceState({
|
||||
connected: false,
|
||||
shouldBeConnected: false,
|
||||
participants: [],
|
||||
muted: false,
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
type VoiceState = {
|
||||
connected: boolean;
|
||||
shouldBeConnected: boolean;
|
||||
participants: string[];
|
||||
muted: boolean;
|
||||
};
|
||||
|
||||
const state: VoiceState = {
|
||||
connected: false,
|
||||
shouldBeConnected: false,
|
||||
participants: [],
|
||||
muted: false,
|
||||
};
|
||||
|
||||
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)',
|
||||
};
|
||||
16
src/renderer/types/rooms.ts
Normal file
16
src/renderer/types/rooms.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type RoomInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
users: string[]; // usernames
|
||||
};
|
||||
|
||||
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