voice test
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={
|
||||||
|
|||||||
@ -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 =
|
||||||
@ -501,6 +509,14 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
|
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Голосовой чат"
|
||||||
|
disableRipple={true}
|
||||||
|
sx={[
|
||||||
|
...tabBaseSx,
|
||||||
|
selectedTab === 4 ? theme.launcher.topbar.tabActive : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CustomTooltip>
|
</CustomTooltip>
|
||||||
</Box>
|
</Box>
|
||||||
@ -536,7 +552,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
marginRight: '1vw',
|
marginRight: '1vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lastVersion &&
|
{lastVersion && (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
title={getLastLaunchLabel(lastVersion)}
|
title={getLastLaunchLabel(lastVersion)}
|
||||||
arrow
|
arrow
|
||||||
@ -608,7 +624,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<span style={{ fontSize: '1vw' }}>⚡</span>
|
<span style={{ fontSize: '1vw' }}>⚡</span>
|
||||||
</Button>
|
</Button>
|
||||||
</CustomTooltip>
|
</CustomTooltip>
|
||||||
}
|
)}
|
||||||
{!isLoginPage && !isRegistrationPage && username && (
|
{!isLoginPage && !isRegistrationPage && username && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
||||||
<HeadAvatar
|
<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