fix screen sharing

This commit is contained in:
2026-04-21 02:29:33 -04:00
parent 43c44ec05e
commit 466678052e
5 changed files with 155 additions and 82 deletions
+56 -16
View File
@@ -1,25 +1,34 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte"; import type { ChatService } from "../services/chat.svelte";
import type { WebRTCService } from "../services/webrtc/webrtc.svelte";
import type * as Types from "../../module_bindings/types"; import type * as Types from "../../module_bindings/types";
import Avatar from "./Avatar.svelte"; import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte"; import Skeleton from "./Skeleton.svelte";
const chat = getContext<ChatService>("chat"); const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc");
let onlineMembers = $derived(chat.activeServerMembers.filter((m) => m.online)); let onlineMembers = $derived(chat.activeServerMembers.filter((m) => m.online));
let offlineMembers = $derived(chat.activeServerMembers.filter((m) => !m.online)); let offlineMembers = $derived(chat.activeServerMembers.filter((m) => !m.online));
function isTalking(member: Types.ServerMember) { function getVoiceState(member: Types.ServerMember) {
return chat.userStates.find((s) => s.identity.isEqual(member.identity))?.isTalking || false; return chat.userStates.find((s) => s.identity.isEqual(member.identity));
} }
function isSharing(member: Types.ServerMember) { function getVoiceStatusColor(member: Types.ServerMember) {
return chat.userStates.find((s) => s.identity.isEqual(member.identity))?.isSharingScreen || false; const state = getVoiceState(member);
} if (!state || state.channelId === undefined) return null;
function isMe(member: Types.ServerMember) { if (chat.identity?.isEqual(member.identity)) return "green";
return chat.identity?.isEqual(member.identity);
const status = webrtc.peerStatuses.get(member.identity.toHexString());
if (!status) return "yellow";
const [iceState] = status.toLowerCase().split("/");
if (iceState.includes("connected") || iceState.includes("completed")) return "green";
if (iceState.includes("connecting") || iceState.includes("checking") || iceState.includes("new")) return "yellow";
return "red";
} }
function getStatus(member: Types.ServerMember) { function getStatus(member: Types.ServerMember) {
@@ -57,13 +66,18 @@
</div> </div>
{#each onlineMembers as member (member.identity.toHexString())} {#each onlineMembers as member (member.identity.toHexString())}
{@const status = getStatus(member)} {@const status = getStatus(member)}
{@const voiceState = getVoiceState(member)}
{@const voiceColor = getVoiceStatusColor(member)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}> <div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" isTalking={isTalking(member)} /> <div class="avatar-container">
<Avatar user={member} size="small" isTalking={voiceState?.isTalking} />
<div class="status-dot green overlay"></div>
</div>
<div class="member-details"> <div class="member-details">
<span class="member-name {isTalking(member) ? 'talking' : ''}"> <span class="member-name {voiceState?.isTalking ? 'talking' : ''}">
{member.name || member.identity.toHexString().substring(0, 8)} {member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)} {#if chat.identity?.isEqual(member.identity)}
<span class="me-badge">(You)</span> <span class="me-badge">(You)</span>
{/if} {/if}
</span> </span>
@@ -72,10 +86,14 @@
{/if} {/if}
</div> </div>
<div style="flex: 1;"></div> <div style="flex: 1;"></div>
{#if isSharing(member)} <div class="member-indicators">
{#if voiceState?.isSharingScreen}
<span class="sharing-badge">LIVE</span> <span class="sharing-badge">LIVE</span>
{/if} {/if}
<div class="status-dot green"></div> {#if voiceColor}
<div class="status-dot {voiceColor}" title="Voice connection status"></div>
{/if}
</div>
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -88,11 +106,14 @@
{@const status = getStatus(member)} {@const status = getStatus(member)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, member)}> <div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" /> <div class="avatar-container">
<Avatar user={member} size="small" />
<div class="status-dot grey overlay"></div>
</div>
<div class="member-details"> <div class="member-details">
<span class="member-name"> <span class="member-name">
{member.name || member.identity.toHexString().substring(0, 8)} {member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)} {#if chat.identity?.isEqual(member.identity)}
<span class="me-badge">(You)</span> <span class="me-badge">(You)</span>
{/if} {/if}
</span> </span>
@@ -100,8 +121,6 @@
<div class="member-status" title={status}>{status}</div> <div class="member-status" title={status}>{status}</div>
{/if} {/if}
</div> </div>
<div style="flex: 1;"></div>
<div class="status-dot grey"></div>
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -110,6 +129,27 @@
</div> </div>
<style> <style>
.avatar-container {
position: relative;
flex-shrink: 0;
}
.status-dot.overlay {
position: absolute;
bottom: -2px;
right: -2px;
border: 3px solid var(--background-secondary);
width: 14px;
height: 14px;
z-index: 2;
}
.member-indicators {
display: flex;
align-items: center;
gap: 4px;
}
.me-badge { .me-badge {
font-size: 0.7rem; font-size: 0.7rem;
opacity: 0.6; opacity: 0.6;
@@ -146,7 +146,7 @@
<span class="voice-indicator-icon" title="Muted"><i class="fas fa-microphone-slash"></i></span> <span class="voice-indicator-icon" title="Muted"><i class="fas fa-microphone-slash"></i></span>
{/if} {/if}
{#if isLocalUserInThisChannel && (s.isDeafened || s.isMuted)} {#if isLocalUserInThisChannel}
<div class="status-dot {voiceStatusColor}"></div> <div class="status-dot {voiceStatusColor}"></div>
{/if} {/if}
</div> </div>
@@ -63,16 +63,13 @@ export class ChannelAudioWebRTCService {
void this.peerManager.peerStatuses; void this.peerManager.peerStatuses;
this.peerManager.peers.forEach(async (peer, peerIdHex) => { this.peerManager.peers.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers(); const transceivers = peer.pc.getTransceivers();
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) { const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio');
try {
console.log( if (audioTransceiver && audioTransceiver.sender.track !== audioTrack) {
`[WebRTC][voice] Syncing track for ${peerIdHex} (track: ${audioTrack?.id})`, try {
); console.log(`[WebRTC][voice] Syncing track for ${peerIdHex}`);
await transceivers[0].sender.replaceTrack(audioTrack); await audioTransceiver.sender.replaceTrack(audioTrack);
// If we just attached a real track and we are stable,
// we might need to negotiate if the remote side didn't know we have a track.
// But replaceTrack usually doesn't need negotiation if the transceiver was already sendrecv.
if (audioTrack && peer.pc.signalingState === "stable") { if (audioTrack && peer.pc.signalingState === "stable") {
this.onNegotiationNeeded(peerIdHex, peer.pc); this.onNegotiationNeeded(peerIdHex, peer.pc);
} }
@@ -129,9 +126,14 @@ export class ChannelAudioWebRTCService {
peersToConnect.forEach((id) => { peersToConnect.forEach((id) => {
if (!this.peerManager.peers.has(id)) { if (!this.peerManager.peers.has(id)) {
console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`); console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`);
this.peerManager.createPeerConnection(id, [ const pc = this.peerManager.createPeerConnection(id);
this.localStream?.getAudioTracks()[0] || null, if (pc) {
]); const audioTrack = this.localStream?.getAudioTracks()[0] || null;
const transceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
if (audioTrack) {
transceiver.sender.replaceTrack(audioTrack);
}
}
} }
}); });
@@ -270,6 +272,17 @@ export class ChannelAudioWebRTCService {
await pc.setRemoteDescription( await pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(signal.data)), new RTCSessionDescription(JSON.parse(signal.data)),
); );
// Map local track to the transceiver created by the offer
const transceivers = pc.getTransceivers();
const audioTrack = this.localStream?.getAudioTracks()[0];
const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio');
if (audioTransceiver && audioTrack) {
await audioTransceiver.sender.replaceTrack(audioTrack);
audioTransceiver.direction = 'sendrecv';
}
const answer = await pc.createAnswer(); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer); await pc.setLocalDescription(answer);
console.log(`[WebRTC][voice] Sending Answer to ${peerIdHex.substring(0,8)}`); console.log(`[WebRTC][voice] Sending Answer to ${peerIdHex.substring(0,8)}`);
+28 -37
View File
@@ -196,7 +196,7 @@ export class PeerManagerService {
createPeerConnection = ( createPeerConnection = (
peerIdHex: string, peerIdHex: string,
initialTracks: (MediaStreamTrack | null)[] = [], _initialTracks: (MediaStreamTrack | null)[] = [],
) => { ) => {
if (this.peers.has(peerIdHex)) return this.peers.get(peerIdHex)!.pc; if (this.peers.has(peerIdHex)) return this.peers.get(peerIdHex)!.pc;
@@ -276,7 +276,10 @@ export class PeerManagerService {
`[WebRTC][${this.mediaType}] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`, `[WebRTC][${this.mediaType}] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`,
); );
const nextPeers = new Map(this.peers); const nextPeers = new Map(this.peers);
const existingPeer = { ...(nextPeers.get(peerIdHex) || { pc }) }; const existingPeer = nextPeers.get(peerIdHex);
if (!existingPeer) return;
const peerUpdate = { ...existingPeer };
if (event.track.kind === "audio") { if (event.track.kind === "audio") {
if (this.mediaType === "voice") { if (this.mediaType === "voice") {
@@ -289,12 +292,12 @@ export class PeerManagerService {
const ctx = this.getAudioContext(); const ctx = this.getAudioContext();
const stream = new MediaStream([event.track]); const stream = new MediaStream([event.track]);
if (!existingPeer.audio) { if (!peerUpdate.audio) {
existingPeer.audio = new Audio(); peerUpdate.audio = new Audio();
existingPeer.audio.muted = true; peerUpdate.audio.muted = true;
} }
existingPeer.audio.srcObject = stream; peerUpdate.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => { peerUpdate.audio.play().catch((err) => {
if (err.name !== "AbortError") if (err.name !== "AbortError")
console.warn( console.warn(
`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`, `[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`,
@@ -310,7 +313,7 @@ export class PeerManagerService {
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
existingPeer.gainNode = gainNode; peerUpdate.gainNode = gainNode;
console.log( console.log(
`[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`, `[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`,
); );
@@ -319,21 +322,21 @@ export class PeerManagerService {
`[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`, `[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`,
e, e,
); );
if (!existingPeer.audio) { if (!peerUpdate.audio) {
existingPeer.audio = new Audio(); peerUpdate.audio = new Audio();
existingPeer.audio.autoplay = true; peerUpdate.audio.autoplay = true;
const pref = this.peerAudioPreferences.get(peerIdHex) || { const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1, volume: 1,
muted: false, muted: false,
}; };
existingPeer.audio.volume = Math.min(1, pref.volume); peerUpdate.audio.volume = Math.min(1, pref.volume);
existingPeer.audio.muted = this.isDeafened || pref.muted; peerUpdate.audio.muted = this.isDeafened || pref.muted;
} }
const stream = new MediaStream([event.track]); const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream; peerUpdate.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => { peerUpdate.audio.play().catch((err) => {
if (err.name !== "AbortError") if (err.name !== "AbortError")
console.error( console.error(
`[WebRTC][voice] Error playing audio for ${peerIdHex}`, `[WebRTC][voice] Error playing audio for ${peerIdHex}`,
@@ -342,44 +345,32 @@ export class PeerManagerService {
}); });
} }
} else { } else {
const currentStream = existingPeer.videoStream || new MediaStream(); const currentStream = peerUpdate.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) { if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track); currentStream.addTrack(event.track);
} }
existingPeer.videoStream = new MediaStream(currentStream.getTracks()); peerUpdate.videoStream = new MediaStream(currentStream.getTracks());
} }
} else if (event.track.kind === "video") { } else if (event.track.kind === "video") {
const currentStream = existingPeer.videoStream || new MediaStream(); const currentStream = peerUpdate.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) { if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track); currentStream.addTrack(event.track);
} }
existingPeer.videoStream = new MediaStream(currentStream.getTracks()); peerUpdate.videoStream = new MediaStream(currentStream.getTracks());
} }
nextPeers.set(peerIdHex, existingPeer); nextPeers.set(peerIdHex, peerUpdate);
this.peers = nextPeers; this.peers = nextPeers;
}; };
if (this.mediaType === "voice") {
pc.addTransceiver("audio", { direction: "sendrecv" });
} else {
pc.addTransceiver("video", { direction: "sendrecv" });
pc.addTransceiver("audio", { direction: "sendrecv" });
}
const transceivers = pc.getTransceivers();
initialTracks.forEach((track, i) => {
if (track && transceivers[i]) {
console.log(
`[WebRTC][${this.mediaType}] Attaching initial track ${i} to ${peerIdHex}`,
);
transceivers[i].sender.replaceTrack(track);
}
});
const nextPeers = new Map(this.peers); const nextPeers = new Map(this.peers);
nextPeers.set(peerIdHex, { pc }); nextPeers.set(peerIdHex, { pc });
this.peers = nextPeers; this.peers = nextPeers;
// We no longer add transceivers here.
// The calling service is responsible for adding them if it's the offerer,
// or setRemoteDescription will create them if it's the answerer.
return pc; return pc;
}; };
@@ -61,20 +61,21 @@ export class ScreenSharingWebRTCService {
this.peerManager.peers.forEach(async (peer, peerIdHex) => { this.peerManager.peers.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers(); const transceivers = peer.pc.getTransceivers();
let changed = false; let changed = false;
if (transceivers[0] && transceivers[0].sender.track !== videoTrack) {
console.log( const videoTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' || t.sender.track?.kind === 'video');
`[WebRTC][screen] Syncing video track for ${peerIdHex} (track: ${videoTrack?.id})`, const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio');
);
await transceivers[0].sender.replaceTrack(videoTrack); if (videoTransceiver && videoTransceiver.sender.track !== videoTrack) {
console.log(`[WebRTC][screen] Syncing video track for ${peerIdHex}`);
await videoTransceiver.sender.replaceTrack(videoTrack);
changed = true; changed = true;
} }
if (transceivers[1] && transceivers[1].sender.track !== audioTrack) { if (audioTransceiver && audioTransceiver.sender.track !== audioTrack) {
console.log( console.log(`[WebRTC][screen] Syncing audio track for ${peerIdHex}`);
`[WebRTC][screen] Syncing audio track for ${peerIdHex} (track: ${audioTrack?.id})`, await audioTransceiver.sender.replaceTrack(audioTrack);
);
await transceivers[1].sender.replaceTrack(audioTrack);
changed = true; changed = true;
} }
if (changed && peer.pc.signalingState === "stable") { if (changed && peer.pc.signalingState === "stable") {
this.onNegotiationNeeded(peerIdHex, peer.pc); this.onNegotiationNeeded(peerIdHex, peer.pc);
} }
@@ -128,10 +129,19 @@ export class ScreenSharingWebRTCService {
screenPeersToConnect.forEach((id) => { screenPeersToConnect.forEach((id) => {
if (!this.peerManager.peers.has(id)) { if (!this.peerManager.peers.has(id)) {
console.log(`[WebRTC][screen] Connecting to watched peer ${id.substring(0,8)}`); console.log(`[WebRTC][screen] Connecting to watched peer ${id.substring(0,8)}`);
this.peerManager.createPeerConnection(id, [ const pc = this.peerManager.createPeerConnection(id);
this.localScreenStream?.getVideoTracks()[0] || null, if (pc) {
this.localScreenStream?.getAudioTracks()[0] || null, // Initiate with desired transceivers
]); pc.addTransceiver('video', { direction: 'sendrecv' });
pc.addTransceiver('audio', { direction: 'sendrecv' });
// Attach initial tracks if available
const transceivers = pc.getTransceivers();
const videoTrack = this.localScreenStream?.getVideoTracks()[0];
const audioTrack = this.localScreenStream?.getAudioTracks()[0];
if (videoTrack) transceivers[0].sender.replaceTrack(videoTrack);
if (audioTrack) transceivers[1].sender.replaceTrack(audioTrack);
}
} }
}); });
@@ -271,6 +281,25 @@ export class ScreenSharingWebRTCService {
await pc.setRemoteDescription( await pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(signal.data)), new RTCSessionDescription(JSON.parse(signal.data)),
); );
// After setting remote offer, the transceivers exist.
// We should attach our local tracks if we have them.
const transceivers = pc.getTransceivers();
const videoTrack = this.localScreenStream?.getVideoTracks()[0];
const audioTrack = this.localScreenStream?.getAudioTracks()[0];
const videoTransceiver = transceivers.find(t => t.receiver.track.kind === 'video');
const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio');
if (videoTransceiver && videoTrack) {
await videoTransceiver.sender.replaceTrack(videoTrack);
videoTransceiver.direction = 'sendrecv';
}
if (audioTransceiver && audioTrack) {
await audioTransceiver.sender.replaceTrack(audioTrack);
audioTransceiver.direction = 'sendrecv';
}
const answer = await pc.createAnswer(); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer); await pc.setLocalDescription(answer);
console.log(`[WebRTC][screen] Sending Answer to ${peerIdHex.substring(0,8)}`); console.log(`[WebRTC][screen] Sending Answer to ${peerIdHex.substring(0,8)}`);