diff --git a/app/src/components/call/composables/useVideoLayout.ts b/app/src/components/call/composables/useVideoLayout.ts
index 194b0deba..969f1a01b 100644
--- a/app/src/components/call/composables/useVideoLayout.ts
+++ b/app/src/components/call/composables/useVideoLayout.ts
@@ -10,6 +10,22 @@ import {
import { storeToRefs } from 'pinia';
import { computed, watch } from 'vue';
+export type Participant = {
+ isMe: boolean;
+ did: string;
+ inCall: boolean;
+ stream: MediaStream | undefined;
+ streamReady: boolean;
+ audioState: MediaState;
+ videoState: MediaState;
+ screenShareState: MediaState;
+ warning: MediaPlayerWarning;
+ // Distinguishes a camera tile from a screenshare tile so a single user
+ // can be rendered as two participants when they're sharing their screen
+ // alongside their webcam.
+ streamKind: 'camera' | 'screenshare';
+};
+
export function useVideoLayout() {
const appStore = useAppStore();
const mediaDeviceStore = useMediaDevicesStore();
@@ -17,8 +33,9 @@ export function useVideoLayout() {
const webrtcStore = useWebrtcStore();
const { me } = storeToRefs(appStore);
- const { stream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
- const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId } = storeToRefs(uiStore);
+ const { stream, screenShareStream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
+ const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId, selfViewVisible } =
+ storeToRefs(uiStore);
const { inCall, peerConnections, disconnectedAgents } = storeToRefs(webrtcStore);
const videoLayoutOptions: VideoLayoutOption[] = [
@@ -37,41 +54,112 @@ export function useVideoLayout() {
if (microphone && microphone.requested && !microphone.granted) warning = 'mic-disabled';
else if (videoEnabled && camera && camera.requested && !camera.granted) warning = 'camera-disabled';
- const myAgent = {
- isMe: true,
- did: me.value.did,
- inCall: inCall.value,
- stream: stream.value || undefined,
- streamReady: true,
- audioState: (audioEnabled ? 'on' : 'off') as MediaState,
- videoState: (videoEnabled ? 'on' : 'off') as MediaState,
- screenShareState: (screenShareEnabled ? 'on' : 'off') as MediaState,
- warning,
- };
-
- const otherAgents = peers.value.map((peer) => ({
- isMe: false,
- did: peer.did,
- inCall: true,
- stream: peer.streams?.[0] || undefined,
- streamReady: peer.streamReady,
- audioState: peer.audioState,
- videoState: peer.videoState,
- screenShareState: peer.screenShareState,
- warning: '' as MediaPlayerWarning,
- }));
-
- return [myAgent, ...otherAgents];
+ const myParticipants: Participant[] = [];
+
+ // Local camera tile. Hidden when the user toggles self-view off so they
+ // can focus on the other participants while still being in the call.
+ if (selfViewVisible.value) {
+ myParticipants.push({
+ isMe: true,
+ did: me.value.did,
+ inCall: inCall.value,
+ stream: stream.value || undefined,
+ streamReady: true,
+ audioState: (audioEnabled ? 'on' : 'off') as MediaState,
+ videoState: (videoEnabled ? 'on' : 'off') as MediaState,
+ // Camera tile never shows the screenshare badge — when both are on
+ // we emit a separate screenshare tile below.
+ screenShareState: 'off' as MediaState,
+ warning,
+ streamKind: 'camera',
+ });
+ }
+
+ // Local screenshare tile. Emitted as its own participant so the camera
+ // and screenshare can be rendered side-by-side instead of one replacing
+ // the other. Always shown to the local user (even when self-view is
+ // off) because hiding your own screenshare would make it impossible to
+ // verify what you're broadcasting.
+ if (screenShareEnabled && screenShareStream.value) {
+ myParticipants.push({
+ isMe: true,
+ did: me.value.did,
+ inCall: inCall.value,
+ stream: screenShareStream.value,
+ streamReady: true,
+ audioState: 'off' as MediaState,
+ videoState: 'off' as MediaState,
+ screenShareState: 'on' as MediaState,
+ warning: '' as MediaPlayerWarning,
+ streamKind: 'screenshare',
+ });
+ }
+
+ const otherAgents: Participant[] = peers.value.flatMap((peer) => {
+ const streams = peer.streams ?? [];
+
+ // Treat a stream as a screenshare when it carries video without audio.
+ // The camera stream always has at least one audio track (mic), so this
+ // distinguishes the two without needing extra signalling. Falls back
+ // to the legacy single-stream rendering when only one stream exists.
+ const cameraStream = streams.find((s) => s.getAudioTracks().length > 0) ?? streams[0];
+ const screenShareStreams = streams.filter(
+ (s) => s !== cameraStream && s.getVideoTracks().length > 0,
+ );
+
+ const cameraEntry: Participant = {
+ isMe: false,
+ did: peer.did,
+ inCall: true,
+ stream: cameraStream || undefined,
+ streamReady: peer.streamReady,
+ audioState: peer.audioState,
+ videoState: peer.videoState,
+ screenShareState: screenShareStreams.length > 0 ? 'off' : peer.screenShareState,
+ warning: '' as MediaPlayerWarning,
+ streamKind: 'camera',
+ };
+
+ const screenShareEntries: Participant[] = screenShareStreams.map((screenStream) => ({
+ isMe: false,
+ did: peer.did,
+ inCall: true,
+ stream: screenStream,
+ streamReady: peer.streamReady,
+ audioState: 'off' as MediaState,
+ videoState: 'off' as MediaState,
+ screenShareState: 'on' as MediaState,
+ warning: '' as MediaPlayerWarning,
+ streamKind: 'screenshare',
+ }));
+
+ return [cameraEntry, ...screenShareEntries];
+ });
+
+ return [...myParticipants, ...otherAgents];
});
+ // Participants may now share a `did` (a camera tile + a screenshare tile
+ // for the same user), so the focus key is the composite `did:streamKind`.
+ // Plain DIDs from older state still match — they fall through to the
+ // first tile we find for that user, which is the camera tile.
+ function participantKey(p: Participant): string {
+ return `${p.did}:${p.streamKind}`;
+ }
+
+ function matchesFocus(p: Participant, focusedId: string): boolean {
+ if (!focusedId) return false;
+ return participantKey(p) === focusedId || p.did === focusedId;
+ }
+
const focusedParticipant = computed(() => {
const focusedId = focusedVideoId.value || me.value.did;
- return allParticipants.value.find((p) => p.did === focusedId) || allParticipants.value[0];
+ return allParticipants.value.find((p) => matchesFocus(p, focusedId)) || allParticipants.value[0];
});
const unfocusedParticipants = computed(() => {
const focusedId = focusedVideoId.value || me.value.did;
- return allParticipants.value.filter((p) => p.did !== focusedId);
+ return allParticipants.value.filter((p) => !matchesFocus(p, focusedId));
});
const numberOfColumns = computed(() => {
@@ -88,9 +176,11 @@ export function useVideoLayout() {
uiStore.setVideoLayout(layout);
}
- function focusOnVideo(did: string) {
+ function focusOnVideo(key: string) {
if (!inCall.value) return;
- uiStore.setFocusedVideoId(did);
+ // `key` is either a participant key (`did:streamKind`) or a bare DID
+ // from older callers; both are honoured by `matchesFocus` above.
+ uiStore.setFocusedVideoId(key);
if (selectedVideoLayout.value.label !== 'Focused') {
uiStore.setVideoLayout(videoLayoutOptions[2]);
}
@@ -133,5 +223,6 @@ export function useVideoLayout() {
selectVideoLayout,
focusOnVideo,
closeFocusedVideoLayout,
+ participantKey,
};
}
diff --git a/app/src/components/call/controls/MainCallControls.vue b/app/src/components/call/controls/MainCallControls.vue
index c4e332382..2534019d0 100644
--- a/app/src/components/call/controls/MainCallControls.vue
+++ b/app/src/components/call/controls/MainCallControls.vue
@@ -71,6 +71,22 @@
+
+
+
+
+
+
@@ -153,7 +169,7 @@ const modalStore = useModalStore();
const aiStore = useAiStore();
const { me } = storeToRefs(appStore);
-const { callWindowFullscreen, isMobile, isLandscapeMobile } = storeToRefs(uiStore);
+const { callWindowFullscreen, isMobile, isLandscapeMobile, selfViewVisible } = storeToRefs(uiStore);
const { mediaSettings, availableDevices } = storeToRefs(mediaDeviceStore);
const { transcriptionEnabled } = storeToRefs(aiStore);
const { inCall, hasCopiedLink } = storeToRefs(webrtcStore);
diff --git a/app/src/components/call/window/VideoGrid.vue b/app/src/components/call/window/VideoGrid.vue
index ea0ab470c..0b9c7b84a 100644
--- a/app/src/components/call/window/VideoGrid.vue
+++ b/app/src/components/call/window/VideoGrid.vue
@@ -1,8 +1,19 @@
@@ -11,7 +22,7 @@
@@ -30,7 +41,7 @@
@@ -71,13 +86,13 @@
import MediaPlayer from '@/components/media-player/MediaPlayer.vue';
import { useWebrtcStore, useUiStore } from '@/stores';
import { storeToRefs } from 'pinia';
-import { computed, onMounted, watch } from 'vue';
+import { computed, watch } from 'vue';
import { useVideoLayout } from '../composables/useVideoLayout';
const webrtcStore = useWebrtcStore();
const uiStore = useUiStore();
const { callEmojis } = storeToRefs(webrtcStore);
-const { isMobile, isLandscapeMobile } = storeToRefs(uiStore);
+const { isMobile, isLandscapeMobile, callWindowFullscreen } = storeToRefs(uiStore);
const {
selectedVideoLayout,
@@ -87,13 +102,56 @@ const {
unfocusedParticipants,
focusOnVideo,
closeFocusedVideoLayout,
+ participantKey,
} = useVideoLayout();
+// Centring the trailing row when it doesn't fill all columns means we have
+// to know two things at template time: how many rows the grid will use
+// (so the auto-fit CSS can divide remaining height evenly) and how many
+// tiles land in that final row (so we can shift them inward via
+// `grid-column-start`).
+const numberOfRows = computed(() => {
+ const total = allParticipants.value.length;
+ const cols = numberOfColumns.value;
+ if (total === 0 || cols <= 0) return 1;
+ return Math.ceil(total / cols);
+});
+
+const lastRowTiles = computed(() => {
+ const total = allParticipants.value.length;
+ const cols = numberOfColumns.value;
+ if (total === 0 || cols <= 0) return 0;
+ const remainder = total % cols;
+ return remainder === 0 ? cols : remainder;
+});
+
+const firstTileInLastRowIndex = computed(() => {
+ if (lastRowTiles.value === numberOfColumns.value) return -1;
+ return allParticipants.value.length - lastRowTiles.value;
+});
+
+function isLastRowOffsetTile(index: number): boolean {
+ // Only the very first tile of an incomplete trailing row needs an
+ // explicit column offset; the others fall into the next grid cell
+ // automatically once that first tile is shifted.
+ return index === firstTileInLastRowIndex.value;
+}
+
+function lastRowStyleFor(index: number): Record | undefined {
+ if (!isLastRowOffsetTile(index)) return undefined;
+ // Centre the partial row by leaving `(cols - tiles) / 2` empty columns to
+ // its left. Using `grid-column-start` is enough — subsequent tiles flow
+ // naturally and the trailing row reads as visually centred.
+ const offset = Math.floor((numberOfColumns.value - lastRowTiles.value) / 2);
+ if (offset <= 0) return undefined;
+ return { 'grid-column-start': String(offset + 1) };
+}
+
// Automatically focus on the first participant when switching to landscape mobile in focused layout
watch(
isLandscapeMobile,
(newVal) => {
- if (newVal && focusedParticipant.value) focusOnVideo(focusedParticipant.value.did);
+ if (newVal && focusedParticipant.value) focusOnVideo(participantKey(focusedParticipant.value));
},
{ immediate: true },
);
@@ -138,6 +196,31 @@ watch(
justify-items: center;
}
+ // Fullscreen / expanded grids should make every tile fit on screen
+ // instead of overflowing. Switch from `min-content` rows to N equal
+ // rows that share the available height, and cap each tile's width by
+ // its 16/9 aspect ratio so they don't stretch into bands when the
+ // available height is the tighter axis.
+ &.fullscreen {
+ height: 100%;
+ overflow: hidden;
+ grid-auto-rows: unset;
+ grid-template-rows: repeat(var(--number-of-rows), 1fr);
+ justify-content: center;
+
+ > div {
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ max-width: 100%;
+ // Preserve 16/9: when height is the limiting axis, `min()` clamps
+ // width so tiles don't get letterboxed unevenly within their grid cell.
+ // (CSS aspect-ratio still applies — `min()` is the upper bound.)
+ align-self: center;
+ justify-self: center;
+ }
+ }
+
&.flexible {
height: 100%;
grid-auto-rows: unset;
diff --git a/app/src/stores/mediaDevicesStore.ts b/app/src/stores/mediaDevicesStore.ts
index aa3485474..6fd44398a 100644
--- a/app/src/stores/mediaDevicesStore.ts
+++ b/app/src/stores/mediaDevicesStore.ts
@@ -45,9 +45,12 @@ export const useMediaDevicesStore = defineStore(
const streamLoading = ref(false);
const error = ref(null);
const screenShareEnabled = ref(false);
+ // Held in its own stream so the camera tile and the screenshare tile can
+ // be rendered side-by-side. Peers receive this as a separate track via
+ // `webrtcStore.addScreenShareTrack`.
+ const screenShareStream = ref(null);
const audioEnabled = ref(true);
const videoEnabled = ref(false);
- let savedVideoTrack: MediaStreamTrack | null = null;
// Computed properties
const cameras = computed(() => availableDevices.value.filter((device) => device.kind === 'videoinput'));
@@ -316,7 +319,10 @@ export const useMediaDevicesStore = defineStore(
// Reset screen share state
if (screenShareEnabled.value) {
screenShareEnabled.value = false;
- savedVideoTrack = null;
+ if (screenShareStream.value) {
+ screenShareStream.value.getTracks().forEach((t) => t.stop());
+ screenShareStream.value = null;
+ }
}
}
@@ -386,17 +392,15 @@ export const useMediaDevicesStore = defineStore(
const newStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints });
const newVideoTrack = newStream.getVideoTracks()[0];
- // If screen sharing is enabled, save the new track for later restoration
- if (screenShareEnabled.value) savedVideoTrack = newVideoTrack;
- else {
- // Otherwise, add the new track directly to the stream
- stream.value.addTrack(newVideoTrack);
+ // Camera + screenshare now coexist as separate streams, so the
+ // newly enabled camera track always lands in the main stream and
+ // is forwarded to peers immediately — no swap-on-screenshare-end
+ // bookkeeping is required.
+ stream.value.addTrack(newVideoTrack);
- // Update peer connections
- const { useWebrtcStore } = await import('./webrtcStore');
- const webrtcStore = useWebrtcStore();
- await webrtcStore.addTrack(newVideoTrack, stream.value);
- }
+ const { useWebrtcStore } = await import('./webrtcStore');
+ const webrtcStore = useWebrtcStore();
+ await webrtcStore.addTrack(newVideoTrack, stream.value);
console.log('✅ Added new video track');
} catch (error) {
@@ -409,8 +413,11 @@ export const useMediaDevicesStore = defineStore(
videoEnabled.value = false;
}
}
- } else if (!screenShareEnabled.value) {
- // Disabling video - disable tracks with animation delay
+ } else {
+ // Disabling video - disable tracks with animation delay.
+ // The camera tracks are independent of screenshare now, so disabling
+ // the camera while sharing only kills the camera tile without
+ // affecting the screenshare track.
await new Promise((resolve) => setTimeout(resolve, 300)); // Fade out animation
existingVideoTracks.forEach((track) => (track.enabled = false));
console.log('✅ Disabled video tracks');
@@ -421,80 +428,71 @@ export const useMediaDevicesStore = defineStore(
if (!stream.value) return;
try {
- // Get the screen share track
- const screenShareStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
- const screenShareTrack = screenShareStream.getVideoTracks()[0];
+ // Get the screen share stream as its own MediaStream so peers can
+ // receive it alongside (not in place of) the camera track. The
+ // camera track stays in the existing `stream` ref and continues
+ // sending — both the local user and remote peers see a separate
+ // tile for camera and screenshare.
+ const newScreenShareStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
+ const screenShareTrack = newScreenShareStream.getVideoTracks()[0];
+ if (!screenShareTrack) {
+ // The browser handed us a stream without a video track — rare but
+ // possible if the user immediately cancelled the picker. Bail
+ // cleanly rather than wiring a phantom share.
+ newScreenShareStream.getTracks().forEach((t) => t.stop());
+ return;
+ }
- // Update my media settings
screenShareEnabled.value = true;
+ screenShareStream.value = newScreenShareStream;
- // Add onended handler to detect when the user stops sharing via browser UI
+ // Detect when the user stops sharing via the browser's native UI
+ // (e.g. the "Stop sharing" bar) and tear the share down cleanly.
screenShareTrack.onended = () => {
if (!screenShareEnabled.value) return;
- screenShareEnabled.value = false;
turnOffScreenShare();
};
- // Get existing video track if present
- const existingVideoTrack = stream.value.getVideoTracks()[0];
-
- // Save existing video track for later restoration
- if (existingVideoTrack) {
- savedVideoTrack = existingVideoTrack;
- // Remove existing video track from stream
- stream.value.removeTrack(existingVideoTrack);
- }
-
- // Add screen share track to existing stream
- stream.value.addTrack(screenShareTrack);
-
- // Update peer connections
+ // Send the screenshare to all peers as its own track + stream so
+ // the receiving side fires a fresh 'track' event with a distinct
+ // stream id, rather than swapping the existing camera sender.
const { useWebrtcStore } = await import('./webrtcStore');
const webrtcStore = useWebrtcStore();
- await webrtcStore.replaceVideoTrack(screenShareTrack, existingVideoTrack);
+ await webrtcStore.addScreenShareTrack(screenShareTrack, newScreenShareStream);
console.log('✅ Successfully started screen share');
} catch (error) {
console.error('❌ Error starting screen share:', error);
screenShareEnabled.value = false;
+ if (screenShareStream.value) {
+ screenShareStream.value.getTracks().forEach((t) => t.stop());
+ screenShareStream.value = null;
+ }
}
}
async function turnOffScreenShare() {
- if (!stream.value) return;
-
try {
- // Get current screen share track
- const screenShareTrack = stream.value.getVideoTracks()[0];
-
- // Remove screen share track from stream
- if (screenShareTrack) {
- stream.value.removeTrack(screenShareTrack);
- screenShareTrack.stop();
- }
-
- // Update media settings
- screenShareEnabled.value = false;
-
- // Restore saved video track if it exists
- if (savedVideoTrack) {
- // Re-enable the saved track if video should be enabled
- savedVideoTrack.enabled = videoEnabled.value;
- stream.value.addTrack(savedVideoTrack);
+ const tracks = screenShareStream.value?.getTracks() ?? [];
- // Update peer connections
- const { useWebrtcStore } = await import('./webrtcStore');
- const webrtcStore = useWebrtcStore();
- await webrtcStore.replaceVideoTrack(savedVideoTrack, screenShareTrack);
+ // Stop the OS-level capture before tearing down the peer senders so
+ // the browser's "Stop sharing" indicator goes away immediately.
+ tracks.forEach((t) => t.stop());
- savedVideoTrack = null; // Clear the saved track
- } else {
- // No saved track - just remove screen share from peers
+ // Remove every screenshare sender from each peer connection. The
+ // receiving side's 'track ended' handler will surface the stream
+ // disappearing — no replace-back to the camera track is needed.
+ if (tracks.length) {
const { useWebrtcStore } = await import('./webrtcStore');
const webrtcStore = useWebrtcStore();
- await webrtcStore.removeTrack(screenShareTrack);
+ for (const t of tracks) {
+ await webrtcStore.removeTrack(t);
+ }
}
+ screenShareEnabled.value = false;
+ screenShareStream.value = null;
+
console.log('✅ Successfully stopped screen share');
} catch (error) {
console.error('❌ Error stopping screen share:', error);
@@ -533,6 +531,7 @@ export const useMediaDevicesStore = defineStore(
activeAudioOutputId,
availableDevices,
stream,
+ screenShareStream,
streamLoading,
error,
screenShareEnabled,
diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts
index b701f7625..51e8bd401 100644
--- a/app/src/stores/uiStore.ts
+++ b/app/src/stores/uiStore.ts
@@ -25,6 +25,10 @@ export const useUiStore = defineStore(
icon: 'aspect-ratio',
});
const focusedVideoId = ref('');
+ // When false the user's own video tile is hidden from the call grid.
+ // The user can still hear themselves and remains a participant — this is
+ // purely a presentation choice so they can focus on others.
+ const selfViewVisible = ref(true);
const showGlobalLoading = ref(false);
const globalError = ref({ show: false, message: '' });
const windowState = ref('visible');
@@ -102,6 +106,14 @@ export const useUiStore = defineStore(
focusedVideoId.value = id;
}
+ function toggleSelfViewVisible(): void {
+ selfViewVisible.value = !selfViewVisible.value;
+ }
+
+ function setSelfViewVisible(visible: boolean): void {
+ selfViewVisible.value = visible;
+ }
+
function setWindowState(state: WindowState): void {
windowState.value = state;
}
@@ -166,6 +178,7 @@ export const useUiStore = defineStore(
callWindowWidth,
selectedVideoLayout,
focusedVideoId,
+ selfViewVisible,
showGlobalLoading,
globalError,
windowState,
@@ -183,6 +196,8 @@ export const useUiStore = defineStore(
setCallWindowWidth,
setVideoLayout,
setFocusedVideoId,
+ toggleSelfViewVisible,
+ setSelfViewVisible,
setWindowState,
setGlobalLoading,
setGlobalError,
diff --git a/app/src/stores/webrtcStore.ts b/app/src/stores/webrtcStore.ts
index 0ff474537..dba50b5c2 100644
--- a/app/src/stores/webrtcStore.ts
+++ b/app/src/stores/webrtcStore.ts
@@ -280,10 +280,31 @@ export const useWebrtcStore = defineStore(
const peerConnection = peerConnections.value.get(did);
if (!peerConnection) return;
- // Check if we already have the stream & update or add accordingly
+ // Append (don't overwrite) — a peer that's sharing their screen
+ // sends both the camera stream and the screenshare stream, and the
+ // old `streams = [stream]` shape silently dropped the camera tile
+ // the moment the screenshare track arrived. New streams append;
+ // tracks added to existing streams update in place.
const existingStreamIndex = peerConnection.streams.findIndex((s) => s.id === stream.id);
if (existingStreamIndex >= 0) peerConnection.streams[existingStreamIndex] = stream;
- else peerConnection.streams = [stream];
+ else peerConnection.streams.push(stream);
+
+ // Drop the stream from this peer's list as soon as all of its tracks
+ // end — guards against stale screenshare tiles after the sender
+ // stops sharing. We can't rely on the peer connection's own
+ // sender-removal because that fires before the receiving track ends.
+ const onTrackEnded = () => {
+ if (track.readyState !== 'ended') return;
+ const pc = peerConnections.value.get(did);
+ if (!pc) return;
+ const streamRef = pc.streams.find((s) => s.id === stream.id);
+ if (!streamRef) return;
+ const liveTracks = streamRef.getTracks().filter((t) => t.readyState !== 'ended');
+ if (liveTracks.length === 0) {
+ pc.streams = pc.streams.filter((s) => s.id !== stream.id);
+ }
+ };
+ track.addEventListener('ended', onTrackEnded);
// Mark the stream as ready if not already set
if (!peerConnection.streamReady) peerConnection.streamReady = true;
@@ -423,6 +444,27 @@ export const useWebrtcStore = defineStore(
}
}
+ // Adds a screen-share track to every peer connection as part of a
+ // dedicated MediaStream (rather than replacing the camera sender).
+ // The receiving side's `peer.on('track')` then fires with a distinct
+ // stream id and the per-peer streams array grows by one entry — the
+ // remote UI gets a separate tile for the screenshare while keeping
+ // the camera tile.
+ async function addScreenShareTrack(track: MediaStreamTrack, screenShareStream: MediaStream) {
+ if (!inCall.value) return;
+
+ console.log('🖥️ Adding screen-share track for all peers');
+
+ for (const [did, peerConnection] of peerConnections.value) {
+ try {
+ peerConnection.peer.addTrack(track, screenShareStream);
+ console.log(`✅ Added screen-share track for peer ${did}`);
+ } catch (error) {
+ console.error(`❌ Failed to add screen-share track for peer ${did}:`, error);
+ }
+ }
+ }
+
async function replaceAudioTrack(newTrack: MediaStreamTrack, oldTrack?: MediaStreamTrack) {
if (!inCall.value) return;
@@ -782,6 +824,7 @@ export const useWebrtcStore = defineStore(
disconnectedAgents,
hasCopiedLink,
addTrack,
+ addScreenShareTrack,
removeTrack,
replaceAudioTrack,
replaceVideoTrack,