voice test

This commit is contained in:
2026-01-02 15:54:58 +05:00
parent 1348b39a4c
commit d90ef2e535
7 changed files with 326 additions and 4 deletions

View File

@ -30,6 +30,7 @@ import FakePaymentPage from './pages/FakePaymentPage';
import { TrayBridge } from './utils/TrayBridge';
import { API_BASE_URL } from './api';
import { PromoRedeem } from './pages/PromoRedeem';
import VoicePage from './pages/VoicePage';
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -376,6 +377,14 @@ const AppLayout = () => {
</AuthCheck>
}
/>
<Route
path="/voice"
element={
<AuthCheck>
<VoicePage />
</AuthCheck>
}
/>
<Route
path="/shop"
element={

View File

@ -25,7 +25,10 @@ import InventoryIcon from '@mui/icons-material/Inventory';
import { RiCoupon3Fill } from 'react-icons/ri';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
import {
isNotificationsEnabled,
getNotifPositionFromSettings,
} from '../utils/notifications';
declare global {
interface Window {
@ -145,6 +148,11 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
match: (p) => p.startsWith('/marketplace'),
to: '/marketplace',
},
{
value: 4,
match: (p) => p.startsWith('/voice'),
to: '/voice',
},
];
const selectedTab =
@ -318,7 +326,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
const ctx = JSON.parse(raw);
const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
localStorage.getItem('launcher_config') || '{}',
);
if (!savedConfig.accessToken) {
@ -501,6 +509,14 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Голосовой чат"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 4 ? theme.launcher.topbar.tabActive : null,
]}
/>
</Tabs>
</CustomTooltip>
</Box>
@ -536,7 +552,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
marginRight: '1vw',
}}
>
{lastVersion &&
{lastVersion && (
<CustomTooltip
title={getLastLaunchLabel(lastVersion)}
arrow
@ -608,7 +624,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<span style={{ fontSize: '1vw' }}></span>
</Button>
</CustomTooltip>
}
)}
{!isLoginPage && !isRegistrationPage && username && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
<HeadAvatar

View File

@ -0,0 +1,31 @@
import { useVoiceRoom } from '../realtime/voice/useVoiceRoom';
export function VoicePanel({
roomId,
username,
}: {
roomId: string;
username: string;
}) {
const voice = useVoiceRoom(roomId, username);
return (
<div>
<button onClick={voice.connect} disabled={voice.connected || !username}>
Войти в голос
</button>
<button onClick={voice.toggleMute}>
{voice.muted ? 'Включить микрофон' : 'Выключить микрофон'}
</button>
<button onClick={voice.disconnect}>Выйти</button>
<ul>
{voice.participants.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { VoicePanel } from '../components/VoicePanel';
export default function VoicePage() {
const [username, setUsername] = useState<string>('');
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.uuid && config.username) {
setUsername(config.username);
}
}
}, []);
if (!username) {
console.warn('Voice: username is empty');
return;
}
return <VoicePanel roomId="test-room" username={username} />;
}

View File

@ -0,0 +1,3 @@
export const rtcConfig: RTCConfiguration = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};

View File

@ -0,0 +1,12 @@
export type WSMessage =
| { type: 'join'; user: string }
| { type: 'leave'; user: string }
| {
type: 'signal';
from: string;
data: {
type: 'offer' | 'answer' | 'ice';
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
};
};

View File

@ -0,0 +1,228 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { rtcConfig } from './rtcConfig';
import type { WSMessage } from './types';
type PeerMap = Map<string, RTCPeerConnection>;
export function useVoiceRoom(roomId: string, username: string) {
const wsRef = useRef<WebSocket | null>(null);
const peersRef = useRef<PeerMap>(new Map());
const streamRef = useRef<MediaStream | 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 () => {
if (wsRef.current) return;
// 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 = () => {
setConnected(true);
setParticipants([username]);
};
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);
setParticipants((p) => (p.includes(msg.user) ? p : [...p, msg.user]));
}
if (msg.type === 'leave') {
removePeer(msg.user);
setParticipants((p) => p.filter((u) => u !== msg.user));
}
if (msg.type === 'signal') {
await handleSignal(msg.from, msg.data);
}
};
}, [roomId, 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;
streamRef.current.getAudioTracks().forEach((t) => {
t.enabled = !t.enabled;
setMuted(!t.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;
document.querySelectorAll('audio[data-user]').forEach((a) => a.remove());
};
const disconnect = () => {
cleanup();
setParticipants([]);
setConnected(false);
};
return {
connect,
disconnect,
toggleMute,
connected,
muted,
participants,
};
}