voice test
This commit is contained in:
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
31
src/renderer/components/VoicePanel.tsx
Normal file
31
src/renderer/components/VoicePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/renderer/pages/VoicePage.tsx
Normal file
23
src/renderer/pages/VoicePage.tsx
Normal 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} />;
|
||||
}
|
||||
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' }],
|
||||
};
|
||||
12
src/renderer/realtime/voice/types.ts
Normal file
12
src/renderer/realtime/voice/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
228
src/renderer/realtime/voice/useVoiceRoom.ts
Normal file
228
src/renderer/realtime/voice/useVoiceRoom.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user