diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b5255df..8229f24 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(null); @@ -376,6 +377,14 @@ const AppLayout = () => { } /> + + + + } + /> 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, ]} /> + @@ -536,7 +552,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) { marginRight: '1vw', }} > - {lastVersion && + {lastVersion && ( - } + )} {!isLoginPage && !isRegistrationPage && username && ( + + + + + + +
    + {voice.participants.map((u) => ( +
  • {u}
  • + ))} +
+ + ); +} diff --git a/src/renderer/pages/VoicePage.tsx b/src/renderer/pages/VoicePage.tsx new file mode 100644 index 0000000..4beb245 --- /dev/null +++ b/src/renderer/pages/VoicePage.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { VoicePanel } from '../components/VoicePanel'; + +export default function VoicePage() { + const [username, setUsername] = useState(''); + + 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 ; +} diff --git a/src/renderer/realtime/voice/rtcConfig.ts b/src/renderer/realtime/voice/rtcConfig.ts new file mode 100644 index 0000000..5bafbf1 --- /dev/null +++ b/src/renderer/realtime/voice/rtcConfig.ts @@ -0,0 +1,3 @@ +export const rtcConfig: RTCConfiguration = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], +}; diff --git a/src/renderer/realtime/voice/types.ts b/src/renderer/realtime/voice/types.ts new file mode 100644 index 0000000..633d24e --- /dev/null +++ b/src/renderer/realtime/voice/types.ts @@ -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; + }; + }; diff --git a/src/renderer/realtime/voice/useVoiceRoom.ts b/src/renderer/realtime/voice/useVoiceRoom.ts new file mode 100644 index 0000000..8884155 --- /dev/null +++ b/src/renderer/realtime/voice/useVoiceRoom.ts @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { rtcConfig } from './rtcConfig'; +import type { WSMessage } from './types'; + +type PeerMap = Map; + +export function useVoiceRoom(roomId: string, username: string) { + const wsRef = useRef(null); + const peersRef = useRef(new Map()); + const streamRef = useRef(null); + + const [connected, setConnected] = useState(false); + const [participants, setParticipants] = useState([]); + const [muted, setMuted] = useState(false); + + const pendingIceRef = useRef>(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, + }; +}