diff --git a/android/app/build.gradle b/android/app/build.gradle
index dca32d25..efa09414 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -7,8 +7,8 @@ android {
applicationId "olcxja.miarven"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 4
- versionName "0.1.3"
+ versionCode 5
+ versionName "0.1.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json
index 11a435fd..01a784ae 100644
--- a/android/app/src/main/assets/public/blah/en-cat.json
+++ b/android/app/src/main/assets/public/blah/en-cat.json
@@ -322,5 +322,7 @@
"label.voice.sensitivity": "input sensitivity",
"label.voice.default": "default",
"error.call.microphone": "microphone error :c",
- "error.voice.reconnect.failed": "voice connection lost :c"
+ "error.voice.reconnect.failed": "voice connection lost :c",
+ "error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version",
+ "error.voice.not.configured": "voice server not configured :c missing VOICE_KEY"
}
diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json
index 79530aeb..fa67cf49 100644
--- a/android/app/src/main/assets/public/blah/en-us.json
+++ b/android/app/src/main/assets/public/blah/en-us.json
@@ -331,5 +331,7 @@
"label.voice.sensitivity": "Input sensitivity",
"label.voice.default": "Default",
"error.call.microphone": "Microphone error",
- "error.voice.reconnect.failed": "Voice connection lost"
+ "error.voice.reconnect.failed": "Voice connection lost",
+ "error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version",
+ "error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)"
}
diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json
index c951e60e..68bed510 100644
--- a/android/app/src/main/assets/public/blah/pl-pl.json
+++ b/android/app/src/main/assets/public/blah/pl-pl.json
@@ -331,5 +331,7 @@
"label.voice.sensitivity": "Czułość wejścia",
"label.voice.default": "Domyślne",
"error.call.microphone": "Błąd mikrofonu",
- "error.voice.reconnect.failed": "Utracono połączenie głosowe"
+ "error.voice.reconnect.failed": "Utracono połączenie głosowe",
+ "error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera",
+ "error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)"
}
diff --git a/android/app/src/main/assets/public/version.js b/android/app/src/main/assets/public/version.js
index 5b1bb6df..ca181587 100644
--- a/android/app/src/main/assets/public/version.js
+++ b/android/app/src/main/assets/public/version.js
@@ -1 +1 @@
-const CLIENT_VERSION = "0.1.3";
+const CLIENT_VERSION = "0.1.4";
diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js
index f1257296..d4cf5d78 100644
--- a/android/app/src/main/assets/public/voice.js
+++ b/android/app/src/main/assets/public/voice.js
@@ -40,7 +40,7 @@ let deadPeers = new Set();
let lastVoiceServerPacketAt = 0;
let voicePingTimer = null;
let voiceReconnectAttempts = 0;
-let lastVoiceServerUrl = null;
+let voiceSocketOpenedAt = 0;
function cleanupPeerMedia(userId) {
for (const aid of [userId, `${userId}_screen`]) {
@@ -640,34 +640,90 @@ class TaskQueue {
clear() { this.queue = []; }
}
+let voiceJoinInFlight = false;
+
async function initiateVoiceCall(roomId = null) {
- if (currentVoiceWs) {
+ if (currentVoiceWs || voiceJoinInFlight) {
showBlahNotification("error:already.in.call");
return;
}
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId;
-
- showAction("action.call.init", "callinit");
+ voiceJoinInFlight = true;
+
+ try {
+ showAction("action.call.init", "callinit");
+ const joined = await requestVoiceJoin();
+ clearAction("callinit");
+
+ if (joined) {
+ await startVoiceSession(joined.serverUrl, joined.ticket);
+ }
+ } finally {
+ voiceJoinInFlight = false;
+ }
+}
+
+// Asks the main server for a voice server IP plus a fresh single-use ticket.
+// The join payload carries publicId/connKey/proof - the client encrypts the
+// proof with connKey (AES-GCM) so the wire never contains a usable credential.
+async function requestVoiceJoin() {
let res = await fetchEncrypted("room/voice/join", JSON.stringify({
string1: currentVoiceCallType,
string2: currentVoiceCallId
}));
- clearAction("callinit");
-
- if (res.startsWith("success:")) {
- let bestIp = res.split("success:")[1];
- if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
- bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
- }
- await startVoiceSession(bestIp);
- } else {
+ if (!res.startsWith("success:")) {
showBlahNotification(res);
+ return null;
+ }
+ const payload = res.substring("success:".length);
+ const parts = payload.split("|");
+ if (parts.length !== 4) return null;
+
+ let serverUrl = parts[0];
+ const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
+ if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
+ serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
+ }
+
+ try {
+ const keyBytes = b64uToBytes(connKey);
+ const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof)));
+ const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`;
+ return {serverUrl, ticket};
+ } catch (e) {
+ console.warn("Voice: failed to build ticket frame", e);
+ return null;
}
}
-async function startVoiceSession(serverUrl) {
- lastVoiceServerUrl = serverUrl;
+function b64uToBytes(s) {
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+function bytesToB64u(a) {
+ let bin = "";
+ for (const byte of a) bin += String.fromCharCode(byte);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+async function startVoiceSession(serverUrl, ticket) {
+ if (!ticket) throw new Error("missing voice ticket");
+ // Single-session invariant: a stale socket (e.g. a double-fired join)
+ // must be replaced BEFORE its onopen sends the same ticket twice -
+ // otherwise the duplicate validation burns the single-use ticket.
+ if (currentVoiceWs) {
+ try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {}
+ currentVoiceWs = null;
+ }
+ const myTicket = ticket; // captured PER INVOCATION - the global may be
+ // overwritten by a racing second join, and reading it inside onopen made
+ // both sockets send the same (already consumed) ticket.
showVoiceUI();
updateVoiceUIUsers();
setVoiceStatus("connecting");
@@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) {
}
wsUrl.searchParams.set("room", currentVoiceCallId);
wsUrl.searchParams.set("userId", window.id);
- wsUrl.searchParams.set("secret", "none");
-
+
currentVoiceWs = new WebSocket(wsUrl.toString());
currentVoiceWs.binaryType = 'arraybuffer';
-
+
currentVoiceWs.onopen = async () => {
+ // Ticket must be the very first frame - the voice server
+ // authenticates the connection before joining it to the room.
+ voiceSocketOpenedAt = Date.now();
+ console.debug("Voice: sending ticket", String(myTicket).split(".")[0]);
+ currentVoiceWs.send(myTicket);
+
setVoiceStatus("connected");
let btn = document.getElementById("voice-btn-mic");
if (btn) {
@@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) {
let vadHangover = 0;
senderNode.port.onmessage = async (e) => {
- if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
// Backpressure valve: when the socket is congested, drop mic
// frames instead of queueing them - queued audio would arrive
// minutes late and drown the connection for every stream.
@@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) {
};
currentVoiceWs.onclose = (ev) => {
+ console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt }));
// Server closes with reason "Kicked" when another connection took
// our userId slot (second device/tab). Reconnecting would start an
// infinite kick-war - give up cleanly instead. Any other close
@@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) {
endVoiceSession();
return;
}
+ // Rejected right after opening = ticket/server-key problem.
+ // Retrying cannot succeed - surface it instead of looping.
+ if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) {
+ console.error("Voice: connection rejected by server:", ev.reason);
+ showBlahNotification("error:voice.ticket.rejected");
+ endVoiceSession();
+ return;
+ }
scheduleVoiceReconnect();
};
@@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) {
let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup";
+ // Remote-controlled values (username/avatar) are NEVER interpolated into
+ // HTML - they are assigned via textContent/src after the skeleton.
incomingUi.innerHTML = `
-
+
- ${cachedName || "..."}
+
${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
`;
+ incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "...";
+ incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png";
document.body.appendChild(incomingUi);
requestAnimationFrame(() => {
requestAnimationFrame(() => incomingUi.classList.add("show"));
@@ -2113,7 +2187,7 @@ function teardownVoiceTransport() {
}
function scheduleVoiceReconnect() {
- if (!currentVoiceCallId || !lastVoiceServerUrl) {
+ if (!currentVoiceCallId || !currentVoiceCallType) {
// Real hangup already ran its cleanup.
return;
}
@@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() {
setVoiceStatus("connecting");
teardownVoiceTransport();
setTimeout(async () => {
- if (!currentVoiceCallId || !lastVoiceServerUrl) return;
+ if (!currentVoiceCallId || !currentVoiceCallType) return;
try {
- await startVoiceSession(lastVoiceServerUrl);
+ // Tickets are single-use - every reconnect needs a fresh one.
+ const joined = await requestVoiceJoin();
+ if (!joined) { endVoiceSession(); return; }
+ await startVoiceSession(joined.serverUrl, joined.ticket);
} catch (err) {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();
diff --git a/electron/package.json b/electron/package.json
index 54499e4e..4b890f9c 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "Miarven",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "An Amazing Capacitor App",
"author": {
"name": "",
diff --git a/package.json b/package.json
index fceed83d..404bb4c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "olcxja.miarven",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "Larpix client",
"license": "AGPL-3.0",
"author": "olcxja's softworks",
diff --git a/version.json b/version.json
index 04746cff..e6d29ba7 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "versionCode": 4,
- "versionName": "0.1.3",
- "npmVersion": "0.1.3"
+ "versionCode": 5,
+ "versionName": "0.1.4",
+ "npmVersion": "0.1.4"
}
diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json
index 11a435fd..01a784ae 100644
--- a/webroot/blah/en-cat.json
+++ b/webroot/blah/en-cat.json
@@ -322,5 +322,7 @@
"label.voice.sensitivity": "input sensitivity",
"label.voice.default": "default",
"error.call.microphone": "microphone error :c",
- "error.voice.reconnect.failed": "voice connection lost :c"
+ "error.voice.reconnect.failed": "voice connection lost :c",
+ "error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version",
+ "error.voice.not.configured": "voice server not configured :c missing VOICE_KEY"
}
diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json
index 79530aeb..fa67cf49 100644
--- a/webroot/blah/en-us.json
+++ b/webroot/blah/en-us.json
@@ -331,5 +331,7 @@
"label.voice.sensitivity": "Input sensitivity",
"label.voice.default": "Default",
"error.call.microphone": "Microphone error",
- "error.voice.reconnect.failed": "Voice connection lost"
+ "error.voice.reconnect.failed": "Voice connection lost",
+ "error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version",
+ "error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)"
}
diff --git a/webroot/blah/pl-pl.json b/webroot/blah/pl-pl.json
index c951e60e..68bed510 100644
--- a/webroot/blah/pl-pl.json
+++ b/webroot/blah/pl-pl.json
@@ -331,5 +331,7 @@
"label.voice.sensitivity": "Czułość wejścia",
"label.voice.default": "Domyślne",
"error.call.microphone": "Błąd mikrofonu",
- "error.voice.reconnect.failed": "Utracono połączenie głosowe"
+ "error.voice.reconnect.failed": "Utracono połączenie głosowe",
+ "error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera",
+ "error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)"
}
diff --git a/webroot/version.js b/webroot/version.js
index 5b1bb6df..ca181587 100644
--- a/webroot/version.js
+++ b/webroot/version.js
@@ -1 +1 @@
-const CLIENT_VERSION = "0.1.3";
+const CLIENT_VERSION = "0.1.4";
diff --git a/webroot/voice.js b/webroot/voice.js
index f1257296..d4cf5d78 100644
--- a/webroot/voice.js
+++ b/webroot/voice.js
@@ -40,7 +40,7 @@ let deadPeers = new Set();
let lastVoiceServerPacketAt = 0;
let voicePingTimer = null;
let voiceReconnectAttempts = 0;
-let lastVoiceServerUrl = null;
+let voiceSocketOpenedAt = 0;
function cleanupPeerMedia(userId) {
for (const aid of [userId, `${userId}_screen`]) {
@@ -640,34 +640,90 @@ class TaskQueue {
clear() { this.queue = []; }
}
+let voiceJoinInFlight = false;
+
async function initiateVoiceCall(roomId = null) {
- if (currentVoiceWs) {
+ if (currentVoiceWs || voiceJoinInFlight) {
showBlahNotification("error:already.in.call");
return;
}
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId;
-
- showAction("action.call.init", "callinit");
+ voiceJoinInFlight = true;
+
+ try {
+ showAction("action.call.init", "callinit");
+ const joined = await requestVoiceJoin();
+ clearAction("callinit");
+
+ if (joined) {
+ await startVoiceSession(joined.serverUrl, joined.ticket);
+ }
+ } finally {
+ voiceJoinInFlight = false;
+ }
+}
+
+// Asks the main server for a voice server IP plus a fresh single-use ticket.
+// The join payload carries publicId/connKey/proof - the client encrypts the
+// proof with connKey (AES-GCM) so the wire never contains a usable credential.
+async function requestVoiceJoin() {
let res = await fetchEncrypted("room/voice/join", JSON.stringify({
string1: currentVoiceCallType,
string2: currentVoiceCallId
}));
- clearAction("callinit");
-
- if (res.startsWith("success:")) {
- let bestIp = res.split("success:")[1];
- if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
- bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
- }
- await startVoiceSession(bestIp);
- } else {
+ if (!res.startsWith("success:")) {
showBlahNotification(res);
+ return null;
+ }
+ const payload = res.substring("success:".length);
+ const parts = payload.split("|");
+ if (parts.length !== 4) return null;
+
+ let serverUrl = parts[0];
+ const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
+ if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
+ serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
+ }
+
+ try {
+ const keyBytes = b64uToBytes(connKey);
+ const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof)));
+ const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`;
+ return {serverUrl, ticket};
+ } catch (e) {
+ console.warn("Voice: failed to build ticket frame", e);
+ return null;
}
}
-async function startVoiceSession(serverUrl) {
- lastVoiceServerUrl = serverUrl;
+function b64uToBytes(s) {
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+function bytesToB64u(a) {
+ let bin = "";
+ for (const byte of a) bin += String.fromCharCode(byte);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+async function startVoiceSession(serverUrl, ticket) {
+ if (!ticket) throw new Error("missing voice ticket");
+ // Single-session invariant: a stale socket (e.g. a double-fired join)
+ // must be replaced BEFORE its onopen sends the same ticket twice -
+ // otherwise the duplicate validation burns the single-use ticket.
+ if (currentVoiceWs) {
+ try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {}
+ currentVoiceWs = null;
+ }
+ const myTicket = ticket; // captured PER INVOCATION - the global may be
+ // overwritten by a racing second join, and reading it inside onopen made
+ // both sockets send the same (already consumed) ticket.
showVoiceUI();
updateVoiceUIUsers();
setVoiceStatus("connecting");
@@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) {
}
wsUrl.searchParams.set("room", currentVoiceCallId);
wsUrl.searchParams.set("userId", window.id);
- wsUrl.searchParams.set("secret", "none");
-
+
currentVoiceWs = new WebSocket(wsUrl.toString());
currentVoiceWs.binaryType = 'arraybuffer';
-
+
currentVoiceWs.onopen = async () => {
+ // Ticket must be the very first frame - the voice server
+ // authenticates the connection before joining it to the room.
+ voiceSocketOpenedAt = Date.now();
+ console.debug("Voice: sending ticket", String(myTicket).split(".")[0]);
+ currentVoiceWs.send(myTicket);
+
setVoiceStatus("connected");
let btn = document.getElementById("voice-btn-mic");
if (btn) {
@@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) {
let vadHangover = 0;
senderNode.port.onmessage = async (e) => {
- if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
// Backpressure valve: when the socket is congested, drop mic
// frames instead of queueing them - queued audio would arrive
// minutes late and drown the connection for every stream.
@@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) {
};
currentVoiceWs.onclose = (ev) => {
+ console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt }));
// Server closes with reason "Kicked" when another connection took
// our userId slot (second device/tab). Reconnecting would start an
// infinite kick-war - give up cleanly instead. Any other close
@@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) {
endVoiceSession();
return;
}
+ // Rejected right after opening = ticket/server-key problem.
+ // Retrying cannot succeed - surface it instead of looping.
+ if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) {
+ console.error("Voice: connection rejected by server:", ev.reason);
+ showBlahNotification("error:voice.ticket.rejected");
+ endVoiceSession();
+ return;
+ }
scheduleVoiceReconnect();
};
@@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) {
let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup";
+ // Remote-controlled values (username/avatar) are NEVER interpolated into
+ // HTML - they are assigned via textContent/src after the skeleton.
incomingUi.innerHTML = `
-
+
- ${cachedName || "..."}
+
${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
`;
+ incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "...";
+ incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png";
document.body.appendChild(incomingUi);
requestAnimationFrame(() => {
requestAnimationFrame(() => incomingUi.classList.add("show"));
@@ -2113,7 +2187,7 @@ function teardownVoiceTransport() {
}
function scheduleVoiceReconnect() {
- if (!currentVoiceCallId || !lastVoiceServerUrl) {
+ if (!currentVoiceCallId || !currentVoiceCallType) {
// Real hangup already ran its cleanup.
return;
}
@@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() {
setVoiceStatus("connecting");
teardownVoiceTransport();
setTimeout(async () => {
- if (!currentVoiceCallId || !lastVoiceServerUrl) return;
+ if (!currentVoiceCallId || !currentVoiceCallType) return;
try {
- await startVoiceSession(lastVoiceServerUrl);
+ // Tickets are single-use - every reconnect needs a fresh one.
+ const joined = await requestVoiceJoin();
+ if (!joined) { endVoiceSession(); return; }
+ await startVoiceSession(joined.serverUrl, joined.ticket);
} catch (err) {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();