Add docker setup and switch voice chat backend to use livekit
This commit is contained in:
2
client/.dockerignore
Normal file
2
client/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
11
client/Dockerfile
Normal file
11
client/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
21
client/nginx.conf
Normal file
21
client/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
location /ws {
|
||||
set $server_upstream http://server:4474;
|
||||
proxy_pass $server_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
140
client/package-lock.json
generated
140
client/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "chat-grid-client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"livekit-client": "^2.9.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -19,6 +20,12 @@
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -709,6 +716,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
|
||||
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
@@ -1059,6 +1081,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1909,6 +1938,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -2127,6 +2165,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
|
||||
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -2185,6 +2232,27 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.17.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
|
||||
"integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.44.0",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"ts-debounce": "^4.0.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "^9.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -2208,6 +2276,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -2498,6 +2579,31 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -2665,6 +2771,18 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-debounce": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
|
||||
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -2678,6 +2796,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -3873,6 +4000,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
|
||||
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-client": "^2.9.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -284,8 +284,6 @@ let heartbeatAwaitingPong = false;
|
||||
let reconnectInFlight = false;
|
||||
let activeServerInstanceId: string | null = null;
|
||||
let reloadScheduledForVersionMismatch = false;
|
||||
let peerNegotiationReady = false;
|
||||
let pendingSignalMessages: Array<Extract<IncomingMessage, { type: 'signal' }>> = [];
|
||||
let peerListenGainByNickname = settings.loadPeerListenGains();
|
||||
let audioLayers: AudioLayerState = {
|
||||
voice: true,
|
||||
@@ -322,10 +320,6 @@ const signaling = new SignalingClient(signalingUrl, handleSignalingStatus);
|
||||
|
||||
const peerManager = new PeerManager(
|
||||
audio,
|
||||
(targetId, payload) => {
|
||||
signaling.send({ type: 'signal', targetId, ...payload });
|
||||
},
|
||||
() => mediaSession.getOutboundStream(),
|
||||
updateStatus,
|
||||
);
|
||||
const mediaSession = new MediaSession({
|
||||
@@ -1277,7 +1271,7 @@ async function checkMicPermission(): Promise<boolean> {
|
||||
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
|
||||
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
||||
await mediaSession.setupLocalMedia(audioDeviceId);
|
||||
applyVoiceSendPermission();
|
||||
authController.applyVoiceSendPermission();
|
||||
}
|
||||
|
||||
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
|
||||
@@ -1497,25 +1491,20 @@ function disconnect(): void {
|
||||
lastSubscriptionRefreshTileY = Math.round(state.player.y);
|
||||
stopTeleportLoopAudio();
|
||||
activeTeleport = null;
|
||||
peerNegotiationReady = false;
|
||||
pendingSignalMessages = [];
|
||||
itemInteractionController.reset();
|
||||
itemBehaviorRegistry.cleanup();
|
||||
}
|
||||
|
||||
/** Starts peer negotiation only after welcome + media setup sequencing is complete. */
|
||||
async function activatePeerNegotiation(): Promise<void> {
|
||||
if (!state.running) return;
|
||||
if (peerNegotiationReady) return;
|
||||
peerNegotiationReady = true;
|
||||
for (const peer of state.peers.values()) {
|
||||
await peerManager.createOrGetPeer(peer.id, true, peer);
|
||||
}
|
||||
if (pendingSignalMessages.length === 0) return;
|
||||
const queued = pendingSignalMessages;
|
||||
pendingSignalMessages = [];
|
||||
for (const signal of queued) {
|
||||
await onAppMessage(signal);
|
||||
/** Connects to the LiveKit room and ensures peers exist for roster members. */
|
||||
async function connectLiveKit(url: string, token: string): Promise<void> {
|
||||
try {
|
||||
await peerManager.connectToRoom(url, token);
|
||||
for (const peer of state.peers.values()) {
|
||||
peerManager.ensurePeer(peer.id, peer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LiveKit connect failed:', error);
|
||||
updateStatus('LiveKit connection failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1593,12 +1582,8 @@ const onAppMessage = createOnMessageHandler({
|
||||
handleAdminUsersList,
|
||||
handleAdminActionResult,
|
||||
handleItemTransferTargets,
|
||||
isPeerNegotiationReady: () => peerNegotiationReady,
|
||||
enqueuePendingSignal: (message) => {
|
||||
pendingSignalMessages.push(message);
|
||||
if (pendingSignalMessages.length > 500) {
|
||||
pendingSignalMessages.splice(0, pendingSignalMessages.length - 500);
|
||||
}
|
||||
connectToLiveKit: (url, token) => {
|
||||
void connectLiveKit(url, token);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1670,14 +1655,12 @@ async function setupMediaAfterAuth(): Promise<void> {
|
||||
const canProceed = await checkMicPermission();
|
||||
if (!canProceed) {
|
||||
setConnectionStatus('Microphone access is required.');
|
||||
await activatePeerNegotiation();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await populateAudioDevices();
|
||||
if (dom.audioInputSelect.options.length === 0) {
|
||||
setConnectionStatus('No audio input device found. Open Audio setup or connect a microphone.');
|
||||
await activatePeerNegotiation();
|
||||
return;
|
||||
}
|
||||
const inputDeviceId = dom.audioInputSelect.value || mediaSession.getPreferredInputDeviceId();
|
||||
@@ -1685,8 +1668,6 @@ async function setupMediaAfterAuth(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setConnectionStatus(describeMediaError(error));
|
||||
} finally {
|
||||
await activatePeerNegotiation();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ type MessageHandlerDeps = {
|
||||
};
|
||||
signalingSend: (message: unknown) => void;
|
||||
peerManager: {
|
||||
createOrGetPeer: (id: string, initiator: boolean, user: { id: string; nickname: string; x: number; y: number }) => Promise<unknown>;
|
||||
handleSignal: (message: IncomingMessage) => Promise<{ id: string; nickname: string; x: number; y: number }>;
|
||||
ensurePeer: (id: string, user: { id: string; nickname: string; x: number; y: number }) => { id: string; nickname: string; x: number; y: number };
|
||||
setPeerPosition: (id: string, x: number, y: number) => void;
|
||||
setPeerNickname: (id: string, nickname: string) => void;
|
||||
removePeer: (id: string) => void;
|
||||
@@ -77,8 +76,7 @@ type MessageHandlerDeps = {
|
||||
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
|
||||
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void;
|
||||
handleItemTransferTargets: (message: Extract<IncomingMessage, { type: 'item_transfer_targets' }>) => void;
|
||||
isPeerNegotiationReady: () => boolean;
|
||||
enqueuePendingSignal: (message: Extract<IncomingMessage, { type: 'signal' }>) => void;
|
||||
connectToLiveKit: (url: string, token: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -153,30 +151,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
||||
deps.gameLoop();
|
||||
break;
|
||||
|
||||
case 'signal': {
|
||||
if (!deps.isPeerNegotiationReady()) {
|
||||
deps.enqueuePendingSignal(message);
|
||||
if (!deps.state.peers.has(message.senderId)) {
|
||||
deps.state.peers.set(message.senderId, {
|
||||
id: message.senderId,
|
||||
userId: null,
|
||||
nickname: deps.sanitizeName(message.senderNickname || 'user...') || 'user...',
|
||||
x: Number.isFinite(message.x) ? message.x : 20,
|
||||
y: Number.isFinite(message.y) ? message.y : 20,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
const peer = await deps.peerManager.handleSignal(message);
|
||||
if (!deps.state.peers.has(peer.id)) {
|
||||
deps.state.peers.set(peer.id, {
|
||||
id: peer.id,
|
||||
userId: null,
|
||||
nickname: deps.sanitizeName(peer.nickname) || 'user...',
|
||||
x: peer.x,
|
||||
y: peer.y,
|
||||
});
|
||||
}
|
||||
case 'livekit_token': {
|
||||
deps.connectToLiveKit(message.url, message.token);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -175,15 +175,10 @@ export const authResultSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const signalMessageSchema = z.object({
|
||||
type: z.literal('signal'),
|
||||
senderId: z.string(),
|
||||
senderNickname: z.string().optional(),
|
||||
x: z.number().int().optional(),
|
||||
y: z.number().int().optional(),
|
||||
targetId: z.string().optional(),
|
||||
sdp: z.any().optional(),
|
||||
ice: z.any().optional(),
|
||||
export const livekitTokenSchema = z.object({
|
||||
type: z.literal('livekit_token'),
|
||||
token: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const updatePositionSchema = z.object({
|
||||
@@ -368,7 +363,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||
authRequiredSchema,
|
||||
authResultSchema,
|
||||
welcomeMessageSchema,
|
||||
signalMessageSchema,
|
||||
livekitTokenSchema,
|
||||
updatePositionSchema,
|
||||
teleportCompleteSchema,
|
||||
updateNicknameSchema,
|
||||
@@ -407,7 +402,6 @@ export type OutgoingMessage =
|
||||
| { type: 'admin_user_ban'; username: string }
|
||||
| { type: 'admin_user_unban'; username: string }
|
||||
| { type: 'admin_user_delete'; username: string }
|
||||
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
||||
| { type: 'update_position'; x: number; y: number }
|
||||
| { type: 'teleport_complete'; x: number; y: number }
|
||||
| { type: 'update_nickname'; nickname: string }
|
||||
|
||||
@@ -459,6 +459,7 @@ export function createAuthController(deps: AuthControllerDeps): {
|
||||
handleAuthResult,
|
||||
handleAuthPermissions,
|
||||
applyWelcomeAuth,
|
||||
applyVoiceSendPermission,
|
||||
logOutAccount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
RemoteTrack,
|
||||
RemoteTrackPublication,
|
||||
RemoteParticipant,
|
||||
LocalTrack,
|
||||
LocalAudioTrack,
|
||||
type AudioCaptureOptions,
|
||||
} from 'livekit-client';
|
||||
import { AudioEngine, type SpatialPeerRuntime } from '../audio/audioEngine';
|
||||
import type { RemoteUser } from '../network/protocol';
|
||||
|
||||
export type PeerRuntime = SpatialPeerRuntime & {
|
||||
id: string;
|
||||
pc: RTCPeerConnection;
|
||||
remoteStream?: MediaStream;
|
||||
};
|
||||
|
||||
type SendSignal = (targetId: string, payload: { sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }) => void;
|
||||
|
||||
type StatusHandler = (message: string) => void;
|
||||
|
||||
export class PeerManager {
|
||||
private readonly peers = new Map<string, PeerRuntime>();
|
||||
private outputDeviceId = '';
|
||||
private room: Room | null = null;
|
||||
private localTrack: LocalAudioTrack | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly audio: AudioEngine,
|
||||
private readonly sendSignal: SendSignal,
|
||||
private readonly getLocalStream: () => MediaStream | null,
|
||||
private readonly status: StatusHandler,
|
||||
) {}
|
||||
|
||||
@@ -30,127 +38,114 @@ export class PeerManager {
|
||||
return this.peers.values();
|
||||
}
|
||||
|
||||
async createOrGetPeer(targetId: string, isInitiator: boolean, userData: Partial<RemoteUser>): Promise<PeerRuntime> {
|
||||
/** Connect to a LiveKit room using the provided token and URL. */
|
||||
async connectToRoom(url: string, token: string): Promise<void> {
|
||||
if (this.room) {
|
||||
await this.room.disconnect();
|
||||
}
|
||||
|
||||
const room = new Room({
|
||||
audioCaptureDefaults: {
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
} as AudioCaptureOptions,
|
||||
audioOutput: {
|
||||
deviceId: this.outputDeviceId || undefined,
|
||||
},
|
||||
publishDefaults: {
|
||||
audioPreset: {
|
||||
maxBitrate: 128_000,
|
||||
},
|
||||
dtx: false,
|
||||
red: true,
|
||||
stopMicTrackOnMute: false,
|
||||
},
|
||||
});
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
if (track.kind !== Track.Kind.Audio) return;
|
||||
void this.handleRemoteTrackSubscribed(participant, track);
|
||||
});
|
||||
|
||||
room.on(RoomEvent.TrackUnsubscribed, (_track: RemoteTrack, _publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
const peer = this.peers.get(participant.identity);
|
||||
if (peer) {
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
peer.remoteStream = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
||||
const peer = this.peers.get(participant.identity);
|
||||
if (peer) {
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
peer.remoteStream = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
this.status('LiveKit disconnected.');
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Reconnecting, () => {
|
||||
this.status('LiveKit reconnecting...');
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Reconnected, () => {
|
||||
this.status('LiveKit reconnected.');
|
||||
});
|
||||
|
||||
await room.connect(url, token);
|
||||
this.room = room;
|
||||
}
|
||||
|
||||
/** Ensure a peer entry exists for a given user (called when roster arrives). */
|
||||
ensurePeer(targetId: string, userData: Partial<RemoteUser>): PeerRuntime {
|
||||
const existing = this.peers.get(targetId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
|
||||
const peer: PeerRuntime = {
|
||||
id: targetId,
|
||||
nickname: userData.nickname ?? 'user...',
|
||||
x: userData.x ?? 20,
|
||||
y: userData.y ?? 20,
|
||||
listenGain: 1,
|
||||
pc,
|
||||
};
|
||||
|
||||
this.peers.set(targetId, peer);
|
||||
|
||||
const stream = this.getLocalStream();
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||
} else {
|
||||
// Ensure initial offers still negotiate audio receive even before mic setup finishes.
|
||||
pc.addTransceiver('audio', { direction: 'sendrecv' });
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.sendSignal(targetId, { ice: event.candidate.toJSON() });
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = async (event) => {
|
||||
peer.remoteStream = event.streams[0];
|
||||
if (this.audio.isVoiceLayerEnabled()) {
|
||||
await this.audio.attachRemoteStream(peer, event.streams[0], this.outputDeviceId);
|
||||
} else {
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
}
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
let offer = await pc.createOffer();
|
||||
offer = this.tuneOpus(offer);
|
||||
await pc.setLocalDescription(offer);
|
||||
this.sendSignal(targetId, { sdp: pc.localDescription ?? undefined });
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
async handleSignal(data: {
|
||||
senderId: string;
|
||||
senderNickname?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
ice?: RTCIceCandidateInit;
|
||||
}): Promise<PeerRuntime> {
|
||||
const peer = await this.createOrGetPeer(data.senderId, false, {
|
||||
id: data.senderId,
|
||||
nickname: data.senderNickname,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
});
|
||||
|
||||
if (data.sdp) {
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
|
||||
if (data.sdp.type === 'offer') {
|
||||
let answer = await peer.pc.createAnswer();
|
||||
answer = this.tuneOpus(answer);
|
||||
await peer.pc.setLocalDescription(answer);
|
||||
this.sendSignal(data.senderId, { sdp: peer.pc.localDescription ?? undefined });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ice) {
|
||||
await peer.pc.addIceCandidate(new RTCIceCandidate(data.ice)).catch(() => undefined);
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
/** Publish a local audio stream to the LiveKit room. */
|
||||
async replaceOutgoingTrack(stream: MediaStream): Promise<void> {
|
||||
const newTrack = stream.getAudioTracks()[0];
|
||||
if (!newTrack) {
|
||||
return;
|
||||
}
|
||||
for (const peer of this.peers.values()) {
|
||||
const sender =
|
||||
peer.pc.getSenders().find((candidate) => candidate.track?.kind === 'audio') ??
|
||||
peer.pc
|
||||
.getTransceivers()
|
||||
.find((transceiver) => transceiver.receiver.track?.kind === 'audio' || transceiver.sender.track?.kind === 'audio')
|
||||
?.sender;
|
||||
if (!sender) {
|
||||
peer.pc.addTrack(newTrack, stream);
|
||||
await this.renegotiatePeer(peer);
|
||||
} else {
|
||||
await sender.replaceTrack(newTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!newTrack) return;
|
||||
|
||||
/** Re-negotiate one peer connection after adding a new outbound track. */
|
||||
private async renegotiatePeer(peer: PeerRuntime): Promise<void> {
|
||||
if (peer.pc.connectionState === 'closed') return;
|
||||
if (peer.pc.signalingState !== 'stable') return;
|
||||
try {
|
||||
let offer = await peer.pc.createOffer();
|
||||
offer = this.tuneOpus(offer);
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
this.sendSignal(peer.id, { sdp: peer.pc.localDescription ?? undefined });
|
||||
} catch {
|
||||
// Best-effort renegotiation; transport-level failures recover on subsequent signaling.
|
||||
if (!this.room) return;
|
||||
|
||||
if (this.localTrack) {
|
||||
// Replace the underlying MediaStreamTrack on the existing LiveKit track.
|
||||
await this.localTrack.replaceTrack(newTrack);
|
||||
} else {
|
||||
const localAudioTrack = new LocalAudioTrack(newTrack, undefined, false);
|
||||
await this.room.localParticipant.publishTrack(localAudioTrack, {
|
||||
audioPreset: {
|
||||
maxBitrate: 128_000,
|
||||
},
|
||||
dtx: false,
|
||||
red: true,
|
||||
stopMicTrackOnMute: false,
|
||||
});
|
||||
this.localTrack = localAudioTrack;
|
||||
}
|
||||
}
|
||||
|
||||
removePeer(id: string): void {
|
||||
const peer = this.peers.get(id);
|
||||
if (!peer) return;
|
||||
peer.pc.close();
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
this.peers.delete(id);
|
||||
}
|
||||
@@ -159,6 +154,11 @@ export class PeerManager {
|
||||
for (const id of this.peers.keys()) {
|
||||
this.removePeer(id);
|
||||
}
|
||||
if (this.room) {
|
||||
void this.room.disconnect();
|
||||
this.room = null;
|
||||
}
|
||||
this.localTrack = null;
|
||||
}
|
||||
|
||||
setPeerPosition(id: string, x: number, y: number): void {
|
||||
@@ -210,24 +210,19 @@ export class PeerManager {
|
||||
}
|
||||
}
|
||||
|
||||
private tuneOpus(desc: RTCSessionDescriptionInit): RTCSessionDescriptionInit {
|
||||
if (!desc.sdp) return desc;
|
||||
const lines = desc.sdp.split('\r\n');
|
||||
let opusPayload: string | undefined;
|
||||
for (const line of lines) {
|
||||
if (line.includes('opus/48000')) {
|
||||
const match = line.match(/(\d+) opus\/48000/);
|
||||
if (match) opusPayload = match[1];
|
||||
}
|
||||
private async handleRemoteTrackSubscribed(participant: RemoteParticipant, track: RemoteTrack): Promise<void> {
|
||||
const mediaStreamTrack = track.mediaStreamTrack;
|
||||
if (!mediaStreamTrack) return;
|
||||
|
||||
const stream = new MediaStream([mediaStreamTrack]);
|
||||
const peer = this.peers.get(participant.identity);
|
||||
if (!peer) return;
|
||||
|
||||
peer.remoteStream = stream;
|
||||
if (this.audio.isVoiceLayerEnabled()) {
|
||||
await this.audio.attachRemoteStream(peer, stream, this.outputDeviceId);
|
||||
} else {
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
}
|
||||
if (opusPayload) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index].includes(`a=fmtp:${opusPayload}`)) {
|
||||
lines[index] += ';maxaveragebitrate=128000;stereo=1;sprop-stereo=1;useinbandfec=1;usedtx=0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...desc, sdp: lines.join('\r\n') };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user