From d90ef2e5355dd86b887bb76e1f94a8f43dc7bc15 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Fri, 2 Jan 2026 15:54:58 +0500 Subject: [PATCH 1/5] voice test --- src/renderer/App.tsx | 9 + src/renderer/components/TopBar.tsx | 24 ++- src/renderer/components/VoicePanel.tsx | 31 +++ src/renderer/pages/VoicePage.tsx | 23 ++ src/renderer/realtime/voice/rtcConfig.ts | 3 + src/renderer/realtime/voice/types.ts | 12 ++ src/renderer/realtime/voice/useVoiceRoom.ts | 228 ++++++++++++++++++++ 7 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/VoicePanel.tsx create mode 100644 src/renderer/pages/VoicePage.tsx create mode 100644 src/renderer/realtime/voice/rtcConfig.ts create mode 100644 src/renderer/realtime/voice/types.ts create mode 100644 src/renderer/realtime/voice/useVoiceRoom.ts 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, + }; +} From a76a8b5656b2cdf15899ea9895daba20342a7742 Mon Sep 17 00:00:00 2001 From: aurinex Date: Fri, 2 Jan 2026 16:23:26 +0500 Subject: [PATCH 2/5] voice v1.1 --- src/renderer/components/VoicePanel.tsx | 19 +++++-- src/renderer/realtime/voice/useVoiceRoom.ts | 58 +++++++++++++++------ src/renderer/realtime/voice/voiceStore.ts | 30 +++++++++++ 3 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 src/renderer/realtime/voice/voiceStore.ts diff --git a/src/renderer/components/VoicePanel.tsx b/src/renderer/components/VoicePanel.tsx index cf70480..565bc39 100644 --- a/src/renderer/components/VoicePanel.tsx +++ b/src/renderer/components/VoicePanel.tsx @@ -1,4 +1,6 @@ import { useVoiceRoom } from '../realtime/voice/useVoiceRoom'; +import { useState, useEffect } from 'react'; +import { getVoiceState, subscribeVoice } from '../realtime/voice/voiceStore'; export function VoicePanel({ roomId, @@ -7,19 +9,26 @@ export function VoicePanel({ roomId: string; username: string; }) { - const voice = useVoiceRoom(roomId, username); + const [voice, setVoice] = useState(getVoiceState()); + const { connect, disconnect, toggleMute } = useVoiceRoom(roomId, username); + + useEffect(() => { + return subscribeVoice(() => { + setVoice({ ...getVoiceState() }); + }); + }, []); return (
- - - +
    {voice.participants.map((u) => ( diff --git a/src/renderer/realtime/voice/useVoiceRoom.ts b/src/renderer/realtime/voice/useVoiceRoom.ts index 8884155..776bfcf 100644 --- a/src/renderer/realtime/voice/useVoiceRoom.ts +++ b/src/renderer/realtime/voice/useVoiceRoom.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { rtcConfig } from './rtcConfig'; import type { WSMessage } from './types'; +import { setVoiceState, getVoiceState } from './voiceStore'; type PeerMap = Map; @@ -9,9 +10,9 @@ export function useVoiceRoom(roomId: string, username: string) { const peersRef = useRef(new Map()); const streamRef = useRef(null); - const [connected, setConnected] = useState(false); - const [participants, setParticipants] = useState([]); - const [muted, setMuted] = useState(false); + // const [connected, setConnected] = useState(false); + // const [participants, setParticipants] = useState([]); + // const [muted, setMuted] = useState(false); const pendingIceRef = useRef>(new Map()); @@ -36,27 +37,48 @@ export function useVoiceRoom(roomId: string, username: string) { wsRef.current = ws; ws.onopen = () => { - setConnected(true); - setParticipants([username]); + setVoiceState({ + connected: true, + participants: [username], + }); }; ws.onclose = () => { + setVoiceState({ + connected: false, + participants: [], + muted: false, + }); cleanup(); - setConnected(false); }; + // 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])); + const { participants } = getVoiceState(); + if (!participants.includes(msg.user)) { + setVoiceState({ + participants: [...participants, msg.user], + }); + } } if (msg.type === 'leave') { removePeer(msg.user); - setParticipants((p) => p.filter((u) => u !== msg.user)); + + setVoiceState({ + participants: getVoiceState().participants.filter( + (u) => u !== msg.user, + ), + }); } if (msg.type === 'signal') { @@ -191,10 +213,12 @@ export function useVoiceRoom(roomId: string, username: string) { // --- mute --- const toggleMute = () => { if (!streamRef.current) return; - streamRef.current.getAudioTracks().forEach((t) => { - t.enabled = !t.enabled; - setMuted(!t.enabled); - }); + + const enabled = !getVoiceState().muted; + + streamRef.current.getAudioTracks().forEach((t) => (t.enabled = !enabled)); + + setVoiceState({ muted: enabled }); }; // --- cleanup --- @@ -213,16 +237,16 @@ export function useVoiceRoom(roomId: string, username: string) { const disconnect = () => { cleanup(); - setParticipants([]); - setConnected(false); + setVoiceState({ + connected: false, + participants: [], + muted: false, + }); }; return { connect, disconnect, toggleMute, - connected, - muted, - participants, }; } diff --git a/src/renderer/realtime/voice/voiceStore.ts b/src/renderer/realtime/voice/voiceStore.ts new file mode 100644 index 0000000..cb1e79f --- /dev/null +++ b/src/renderer/realtime/voice/voiceStore.ts @@ -0,0 +1,30 @@ +type VoiceState = { + connected: boolean; + participants: string[]; + muted: boolean; +}; + +const state: VoiceState = { + connected: false, + participants: [], + muted: false, +}; + +const listeners = new Set<() => void>(); + +export function getVoiceState() { + return state; +} + +export function setVoiceState(patch: Partial) { + Object.assign(state, patch); + listeners.forEach((l) => l()); +} + +export function subscribeVoice(cb: () => void): () => void { + listeners.add(cb); + + return () => { + listeners.delete(cb); + }; +} From 4944a18076e046e8079ce98fd5a98895d08adbd4 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Fri, 2 Jan 2026 17:23:23 +0500 Subject: [PATCH 3/5] add voice button in TopBar --- src/renderer/components/TopBar.tsx | 75 ++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index 8e1daa4..33d819b 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -509,14 +509,6 @@ export default function TopBar({ onRegister, username }: TopBarProps) { selectedTab === 3 ? theme.launcher.topbar.tabActive : null, ]} /> - @@ -614,8 +606,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) { bottom: 0, height: '0.15vw', borderRadius: '999px', - background: - 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)', + // background: + // 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)', opacity: 0.9, }, }} @@ -625,6 +617,69 @@ export default function TopBar({ onRegister, username }: TopBarProps) { )} + {!isLoginPage && !isRegistrationPage && username && ( Date: Fri, 2 Jan 2026 19:22:11 +0500 Subject: [PATCH 4/5] voice v2 --- src/renderer/api/voiceRooms.ts | 62 +++++++ src/renderer/components/PageHeader.tsx | 1 + .../components/Voice/CreateRoomDialog.tsx | 112 ++++++++++++ .../components/Voice/JoinByCodeDialog.tsx | 58 +++++++ src/renderer/components/Voice/RoomsPanel.tsx | 115 +++++++++++++ src/renderer/components/Voice/VoicePanel.tsx | 84 +++++++++ src/renderer/components/VoicePanel.tsx | 40 ----- src/renderer/mappers/roomMapper.ts | 11 ++ src/renderer/pages/VoicePage.tsx | 141 ++++++++++++++- src/renderer/realtime/voice/types.ts | 1 + src/renderer/realtime/voice/useVoiceRoom.ts | 160 +++++++++++------- src/renderer/realtime/voice/voiceStore.ts | 2 + src/renderer/theme/voiceStyles.ts | 12 ++ src/renderer/types/rooms.ts | 16 ++ 14 files changed, 709 insertions(+), 106 deletions(-) create mode 100644 src/renderer/api/voiceRooms.ts create mode 100644 src/renderer/components/Voice/CreateRoomDialog.tsx create mode 100644 src/renderer/components/Voice/JoinByCodeDialog.tsx create mode 100644 src/renderer/components/Voice/RoomsPanel.tsx create mode 100644 src/renderer/components/Voice/VoicePanel.tsx delete mode 100644 src/renderer/components/VoicePanel.tsx create mode 100644 src/renderer/mappers/roomMapper.ts create mode 100644 src/renderer/theme/voiceStyles.ts create mode 100644 src/renderer/types/rooms.ts diff --git a/src/renderer/api/voiceRooms.ts b/src/renderer/api/voiceRooms.ts new file mode 100644 index 0000000..6304509 --- /dev/null +++ b/src/renderer/api/voiceRooms.ts @@ -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 { + 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 { + 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 { + 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(); +} diff --git a/src/renderer/components/PageHeader.tsx b/src/renderer/components/PageHeader.tsx index 6256085..d23c090 100644 --- a/src/renderer/components/PageHeader.tsx +++ b/src/renderer/components/PageHeader.tsx @@ -36,6 +36,7 @@ export default function PageHeader() { path === '/inventory' || path === '/fakepaymentpage' || path === '/promocode' || + path === '/voice' || path.startsWith('/launch') ) { return { title: '', subtitle: '', hidden: true }; diff --git a/src/renderer/components/Voice/CreateRoomDialog.tsx b/src/renderer/components/Voice/CreateRoomDialog.tsx new file mode 100644 index 0000000..49e6cf1 --- /dev/null +++ b/src/renderer/components/Voice/CreateRoomDialog.tsx @@ -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(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 ( + + Создать комнату + + + {!inviteCode ? ( + <> + setName(e.target.value)} + margin="dense" + /> + + setIsPublic(e.target.checked)} + /> + } + label={isPublic ? 'Публичная' : 'Приватная'} + sx={{ mt: 1 }} + /> + + ) : ( + <> + Код для входа в комнату: + + + + + + )} + + + + + + {!inviteCode && ( + + )} + + + ); +} diff --git a/src/renderer/components/Voice/JoinByCodeDialog.tsx b/src/renderer/components/Voice/JoinByCodeDialog.tsx new file mode 100644 index 0000000..7387d9b --- /dev/null +++ b/src/renderer/components/Voice/JoinByCodeDialog.tsx @@ -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; +}) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + + const handleJoin = async () => { + setLoading(true); + await onJoin(code); + setLoading(false); + setCode(''); + onClose(); + }; + + return ( + + Войти по коду + + + setCode(e.target.value)} + margin="dense" + /> + + + + + + + + ); +} diff --git a/src/renderer/components/Voice/RoomsPanel.tsx b/src/renderer/components/Voice/RoomsPanel.tsx new file mode 100644 index 0000000..46cd031 --- /dev/null +++ b/src/renderer/components/Voice/RoomsPanel.tsx @@ -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 ( + + + Голосовые комнаты + + + + {rooms.map((room) => { + const active = room.id === currentRoomId; + + return ( + 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)', + }, + }} + > + {room.name} + + {room.users.length > 0 && ( + + {room.users.map((u) => ( + + • {u} + + ))} + + )} + + ); + })} + + + + + + + ); +} diff --git a/src/renderer/components/Voice/VoicePanel.tsx b/src/renderer/components/Voice/VoicePanel.tsx new file mode 100644 index 0000000..e051094 --- /dev/null +++ b/src/renderer/components/Voice/VoicePanel.tsx @@ -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 ( + + + {roomId} + + + + + + + + + Участники: + + {state.participants.map((u) => ( + • {u} + ))} + + ); +} diff --git a/src/renderer/components/VoicePanel.tsx b/src/renderer/components/VoicePanel.tsx deleted file mode 100644 index 565bc39..0000000 --- a/src/renderer/components/VoicePanel.tsx +++ /dev/null @@ -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 ( -
    - - - - - - -
      - {voice.participants.map((u) => ( -
    • {u}
    • - ))} -
    -
    - ); -} diff --git a/src/renderer/mappers/roomMapper.ts b/src/renderer/mappers/roomMapper.ts new file mode 100644 index 0000000..28e2632 --- /dev/null +++ b/src/renderer/mappers/roomMapper.ts @@ -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 + }; +} diff --git a/src/renderer/pages/VoicePage.tsx b/src/renderer/pages/VoicePage.tsx index 4beb245..fb379a0 100644 --- a/src/renderer/pages/VoicePage.tsx +++ b/src/renderer/pages/VoicePage.tsx @@ -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(''); + const [roomDetails, setRoomDetails] = useState(null); + const [username, setUsername] = useState(''); + const [rooms, setRooms] = useState([]); + const [currentRoomId, setCurrentRoomId] = useState(null); + + const [createOpen, setCreateOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + + const voice = useVoiceRoom(username); + + const wsRef = useRef(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 ; + 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 ( +
    + setCreateOpen(true)} + onJoinByCode={() => setJoinOpen(true)} + /> + + setCreateOpen(false)} + onCreate={handleCreateRoom} + /> + + setJoinOpen(false)} + onJoin={handleJoinByCode} + /> + + {currentRoomId && } +
    + ); } diff --git a/src/renderer/realtime/voice/types.ts b/src/renderer/realtime/voice/types.ts index 633d24e..8a751ee 100644 --- a/src/renderer/realtime/voice/types.ts +++ b/src/renderer/realtime/voice/types.ts @@ -1,4 +1,5 @@ export type WSMessage = + | { type: 'users'; users: string[] } | { type: 'join'; user: string } | { type: 'leave'; user: string } | { diff --git a/src/renderer/realtime/voice/useVoiceRoom.ts b/src/renderer/realtime/voice/useVoiceRoom.ts index 776bfcf..087fe07 100644 --- a/src/renderer/realtime/voice/useVoiceRoom.ts +++ b/src/renderer/realtime/voice/useVoiceRoom.ts @@ -5,11 +5,15 @@ import { setVoiceState, getVoiceState } from './voiceStore'; type PeerMap = Map; -export function useVoiceRoom(roomId: string, username: string) { +export function useVoiceRoom(username: string) { const wsRef = useRef(null); const peersRef = useRef(new Map()); const streamRef = useRef(null); + const currentRoomIdRef = useRef(null); + + const reconnectTimeout = useRef(null); + // const [connected, setConnected] = useState(false); // const [participants, setParticipants] = useState([]); // const [muted, setMuted] = useState(false); @@ -17,75 +21,111 @@ export function useVoiceRoom(roomId: string, username: string) { const pendingIceRef = useRef>(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 { diff --git a/src/renderer/realtime/voice/voiceStore.ts b/src/renderer/realtime/voice/voiceStore.ts index cb1e79f..7bb0569 100644 --- a/src/renderer/realtime/voice/voiceStore.ts +++ b/src/renderer/realtime/voice/voiceStore.ts @@ -1,11 +1,13 @@ type VoiceState = { connected: boolean; + shouldBeConnected: boolean; participants: string[]; muted: boolean; }; const state: VoiceState = { connected: false, + shouldBeConnected: false, participants: [], muted: false, }; diff --git a/src/renderer/theme/voiceStyles.ts b/src/renderer/theme/voiceStyles.ts new file mode 100644 index 0000000..ed13a40 --- /dev/null +++ b/src/renderer/theme/voiceStyles.ts @@ -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)', +}; diff --git a/src/renderer/types/rooms.ts b/src/renderer/types/rooms.ts new file mode 100644 index 0000000..1911482 --- /dev/null +++ b/src/renderer/types/rooms.ts @@ -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; +}; From f617940c44ef5559781be8ea1bd1eb037e3e7967 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Fri, 2 Jan 2026 19:50:06 +0500 Subject: [PATCH 5/5] add head avatar to voice --- src/renderer/components/Voice/VoicePanel.tsx | 53 ++++++++++++++++++-- src/renderer/pages/VoicePage.tsx | 27 ++++++++-- src/renderer/types/rooms.ts | 1 + 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/Voice/VoicePanel.tsx b/src/renderer/components/Voice/VoicePanel.tsx index e051094..57d3757 100644 --- a/src/renderer/components/Voice/VoicePanel.tsx +++ b/src/renderer/components/Voice/VoicePanel.tsx @@ -3,6 +3,8 @@ import { useVoiceRoom } from '../../realtime/voice/useVoiceRoom'; import { useState, useEffect } from 'react'; import { getVoiceState, subscribeVoice } from '../../realtime/voice/voiceStore'; import { glassBox, GRADIENT } from '../../theme/voiceStyles'; +import { HeadAvatar } from '../HeadAvatar'; +import { API_BASE_URL } from '../../api'; type Props = { roomId: string; @@ -10,10 +12,40 @@ type Props = { disconnect: () => void; toggleMute: () => void; }; + roomName: string; }; -export function VoicePanel({ roomId, voice }: Props) { +export function VoicePanel({ roomId, voice, roomName }: Props) { const [state, setState] = useState(getVoiceState()); + const [skinMap, setSkinMap] = useState>({}); + + useEffect(() => { + const loadSkins = async () => { + const missing = state.participants.filter((u) => !skinMap[u]); + if (!missing.length) return; + + try { + // ⚠️ ВАЖНО: полный URL до API + const res = await fetch(API_BASE_URL + '/users'); + const data = await res.json(); + + const map: Record = {}; + for (const user of data.users) { + if (missing.includes(user.username) && user.skin_url) { + map[user.username] = user.skin_url; + } + } + + if (Object.keys(map).length) { + setSkinMap((prev) => ({ ...prev, ...map })); + } + } catch (e) { + console.warn('Не удалось загрузить скины для голосового чата', e); + } + }; + + loadSkins(); + }, [state.participants]); useEffect( () => @@ -23,6 +55,9 @@ export function VoicePanel({ roomId, voice }: Props) { [], ); + console.log('participants:', state.participants); + console.log('skinMap:', skinMap); + return ( - {roomId} + {roomName} @@ -77,7 +112,19 @@ export function VoicePanel({ roomId, voice }: Props) { Участники: {state.participants.map((u) => ( - • {u} + + + + {u} + ))} ); diff --git a/src/renderer/pages/VoicePage.tsx b/src/renderer/pages/VoicePage.tsx index fb379a0..0713d24 100644 --- a/src/renderer/pages/VoicePage.tsx +++ b/src/renderer/pages/VoicePage.tsx @@ -11,15 +11,12 @@ 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 [roomDetails, setRoomDetails] = useState(null); - const [username, setUsername] = useState(''); const [rooms, setRooms] = useState([]); const [currentRoomId, setCurrentRoomId] = useState(null); - + const [currentRoom, setCurrentRoom] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [joinOpen, setJoinOpen] = useState(false); @@ -89,7 +86,10 @@ export default function VoicePage() { // --- handlers --- const joinRoom = (roomId: string) => { + const room = rooms.find((r) => r.id === roomId); + if (!room) return; setCurrentRoomId(roomId); + setCurrentRoom(room); voice.connect(roomId); // 🔥 АВТОПОДКЛЮЧЕНИЕ }; @@ -102,6 +102,10 @@ export default function VoicePage() { }) => { const apiRoom = await createPublicRoom(name, username, isPublic); + const room: RoomInfo = mapApiRoomToUI(apiRoom); + + setRooms((prev) => [room, ...prev]); + setCurrentRoomId(apiRoom.id); return { @@ -112,6 +116,13 @@ export default function VoicePage() { const handleJoinByCode = async (code: string) => { const room = await joinPrivateRoom(code); setCurrentRoomId(room.id); + setCurrentRoom({ + id: room.id, + name: room.name, + public: false, + users: [], + maxUsers: room.max_users, + }); }; if (!username) return null; @@ -140,7 +151,13 @@ export default function VoicePage() { onJoin={handleJoinByCode} /> - {currentRoomId && } + {currentRoomId && ( + + )}
); } diff --git a/src/renderer/types/rooms.ts b/src/renderer/types/rooms.ts index 1911482..232800e 100644 --- a/src/renderer/types/rooms.ts +++ b/src/renderer/types/rooms.ts @@ -3,6 +3,7 @@ export type RoomInfo = { name: string; public: boolean; users: string[]; // usernames + maxUsers: number; }; export type RoomDetails = {