This commit is contained in:
aurinex
2026-01-02 19:22:11 +05:00
parent a76a8b5656
commit 8520f2120d
14 changed files with 709 additions and 106 deletions

View 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();
}

View File

@ -36,6 +36,7 @@ export default function PageHeader() {
path === '/inventory' ||
path === '/fakepaymentpage' ||
path === '/promocode' ||
path === '/voice' ||
path.startsWith('/launch')
) {
return { title: '', subtitle: '', hidden: true };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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
};
}

View File

@ -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;
return <VoicePanel roomId="test-room" username={username} />;
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) => {
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>
);
}

View File

@ -1,4 +1,5 @@
export type WSMessage =
| { type: 'users'; users: string[] }
| { type: 'join'; user: string }
| { type: 'leave'; user: string }
| {

View File

@ -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,75 +21,111 @@ export function useVoiceRoom(roomId: string, username: string) {
const pendingIceRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
// --- connect ---
const connect = useCallback(async () => {
if (wsRef.current) return;
const connect = useCallback(
async (roomId: string) => {
if (wsRef.current) return;
// 1. микрофон
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
streamRef.current = stream;
currentRoomIdRef.current = roomId;
// 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,
participants: [username],
// 1. микрофон
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
};
streamRef.current = stream;
ws.onclose = () => {
setVoiceState({
connected: false,
participants: [],
muted: false,
});
cleanup();
};
// 2. websocket
const ws = new WebSocket(
`wss://minecraft.api.popa-popa.ru/ws/voice?room_id=${roomId}&username=${username}`,
);
wsRef.current = ws;
// ws.onclose = () => {
// cleanup();
// setConnected(false);
// };
ws.onopen = () => {
setVoiceState({
connected: true,
shouldBeConnected: true,
participants: [username],
});
};
ws.onmessage = async (ev) => {
const msg: WSMessage = JSON.parse(ev.data);
ws.onclose = () => {
cleanup();
if (msg.type === 'join' && msg.user !== username) {
await createPeer(msg.user, false);
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);
const { participants } = getVoiceState();
if (!participants.includes(msg.user)) {
setVoiceState({
participants: [...participants, msg.user],
participants: getVoiceState().participants.filter(
(u) => u !== msg.user,
),
});
}
}
if (msg.type === 'leave') {
removePeer(msg.user);
if (msg.type === 'signal') {
await handleSignal(msg.from, msg.data);
}
setVoiceState({
participants: getVoiceState().participants.filter(
(u) => u !== msg.user,
),
});
}
if (msg.type === 'users') {
const current = getVoiceState().participants;
const next = msg.users;
if (msg.type === 'signal') {
await handleSignal(msg.from, msg.data);
}
};
}, [roomId, username]);
// 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) => {
@ -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 {

View File

@ -1,11 +1,13 @@
type VoiceState = {
connected: boolean;
shouldBeConnected: boolean;
participants: string[];
muted: boolean;
};
const state: VoiceState = {
connected: false,
shouldBeConnected: false,
participants: [],
muted: false,
};

View 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)',
};

View 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;
};