test
All checks were successful
/ upload (release) Successful in 22s

This commit is contained in:
pavel 2026-02-24 22:28:02 +01:00
commit a5af009ba8
3 changed files with 176 additions and 151 deletions

View file

@ -17,8 +17,9 @@ const state = {
rawStream: null,
videoStream: null,
screenStream: null,
audioContext: null,
peerConnections: new Map(),
videoSenders: new Map(),
screenSenders: new Map(),
muted: false,
sharingVideo: false,
sharingScreen: false,
@ -789,37 +790,37 @@ function ensurePeerConnection(peerId) {
return state.voice.peerConnections.get(peerId);
}
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
const pc = new RTCPeerConnection({
iceServers: state.voice.iceServers
});
pc.makingOffer = false;
pc.ignoreOffer = false;
pc.polite = !shouldInitiateOffer(peerId);
// Add transceivers in a fixed order to ensure stable SDP m-lines: [Audio, Camera, Screen]
// 1. Audio
if (state.voice.localStream) {
pc.addTransceiver(state.voice.localStream.getAudioTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.localStream]
state.voice.localStream.getAudioTracks().forEach(track => {
pc.addTrack(track, state.voice.localStream);
});
} else {
pc.addTransceiver('audio', { direction: 'sendrecv' });
pc.addTransceiver('audio', { direction: 'recvonly' });
}
// 2. Camera Video
if (state.voice.videoStream) {
pc.addTransceiver(state.voice.videoStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.videoStream]
state.voice.videoStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.videoStream);
state.voice.videoSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
// 3. Screen Video
if (state.voice.screenStream) {
pc.addTransceiver(state.voice.screenStream.getVideoTracks()[0], {
direction: 'sendrecv',
streams: [state.voice.screenStream]
state.voice.screenStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.screenStream);
state.voice.screenSenders.set(peerId, sender);
});
} else {
pc.addTransceiver('video', { direction: 'sendrecv' });
}
pc.onicecandidate = (event) => {
@ -833,6 +834,7 @@ function ensurePeerConnection(peerId) {
};
pc.ontrack = (event) => {
const stream = event.streams[0];
if (event.track.kind === "audio") {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
@ -842,24 +844,35 @@ function ensurePeerConnection(peerId) {
audio.playsInline = true;
document.body.appendChild(audio);
}
audio.srcObject = event.streams[0];
audio.srcObject = stream;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
// Distinguish screen share from camera by checking track labels or signaling
// For simplicity, we can use the stream index or a dedicated track check.
// A more robust way is to have separate transceivers/signaling.
// Here we check if the label suggests a screen.
const isScreen = event.track.label.toLowerCase().includes('screen') ||
event.track.label.toLowerCase().includes('monitor');
renderVideo(peerId, peer.display_name, event.streams[0], isScreen ? 'screen' : 'camera');
renderVideo(peerId, peer.display_name, stream, event.track.id);
event.track.onmute = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
event.track.onended = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
}
};
pc.onnegotiationneeded = async () => {
try {
await sendOffer(peerId);
pc.makingOffer = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: pc.localDescription,
}));
} catch (err) {
console.error("negotiation failed", err);
} finally {
pc.makingOffer = false;
}
};
@ -867,41 +880,23 @@ function ensurePeerConnection(peerId) {
return pc;
}
async function sendOffer(peerId) {
const pc = ensurePeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: offer,
}));
}
async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId);
const polite = !shouldInitiateOffer(fromPeerId);
try {
if (kind === "offer") {
const offerCollision = (pc.signalingState !== "stable");
if (offerCollision) {
if (!polite) return;
await Promise.all([
pc.setLocalDescription({ type: "rollback" }),
pc.setRemoteDescription(new RTCSessionDescription(data))
]);
} else {
await pc.setRemoteDescription(new RTCSessionDescription(data));
}
const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
pc.ignoreOffer = !pc.polite && offerCollision;
if (pc.ignoreOffer) return;
await pc.setRemoteDescription(new RTCSessionDescription(data));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: fromPeerId,
kind: "answer",
data: answer,
data: pc.localDescription,
}));
} else if (kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
@ -909,9 +904,7 @@ async function handleSignal(fromPeerId, kind, data) {
try {
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
} catch (err) {
if (pc.signalingState !== "stable") {
// Ignore ICE candidates during negotiation
} else {
if (!pc.ignoreOffer) {
console.warn("failed to add ice candidate", err);
}
}
@ -952,11 +945,11 @@ async function joinVoice() {
const msg = JSON.parse(event.data);
if (msg.type === "peers") {
for (const peer of msg.peers) {
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
ensurePeerConnection(peer.user_id);
}
} else if (msg.type === "peer_joined") {
playSound('peer-join');
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
ensurePeerConnection(msg.user_id);
} else if (msg.type === "peer_left") {
playSound('peer-leave');
const pc = state.voice.peerConnections.get(msg.user_id);
@ -996,6 +989,8 @@ async function joinVoice() {
el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear();
state.voice.videoSenders.clear();
state.voice.screenSenders.clear();
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
@ -1025,11 +1020,11 @@ async function toggleVideo() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
const cameraTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' && t.mid === transceivers[1].mid);
if (cameraTransceiver) {
cameraTransceiver.sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.videoSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.videoSenders.delete(peerId);
}
}
} else {
@ -1043,11 +1038,11 @@ async function toggleVideo() {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[1]) {
transceivers[1].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.videoSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("camera denied", err);
@ -1067,15 +1062,16 @@ async function toggleScreenShare() {
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(null);
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.screenSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.screenSenders.delete(peerId);
}
}
} else {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
state.voice.screenStream = stream;
state.voice.sharingScreen = true;
renderVideo(state.me.id, state.me.display_name, stream, 'screen');
@ -1089,11 +1085,11 @@ async function toggleScreenShare() {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
}
for (const pc of state.voice.peerConnections.values()) {
const transceivers = pc.getTransceivers();
if (transceivers[2]) {
transceivers[2].sender.replaceTrack(stream.getVideoTracks()[0]);
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.screenSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("screen share denied", err);