Skip to content

Commit 6cde515

Browse files
HexaFieldclaude
andcommitted
feat(call): support being in a call from multiple tabs/devices
Calls were pinned to a single tab per browser: a second tab joining a call hit "You are already in a call in another tab." The whole call + presence stack was keyed by DID, so two sessions of the same agent would collide on one presence entry and one peer-connection slot. Change the unit of call identity from DID to a session key (`did::sessionId`, reusing the per-tab UUID already in sessionStorage) and decouple calls from tab-coordinator leadership. The same agent can now be in a call from several tabs/devices at once. Participant model: every video/screenshare feed renders as its own tile, no-video sessions collapse to a single avatar per person, and audio is deduped to one feed per person (no echo). - types: AgentState gains `did` + `sessionId` - useTabCoordinator: drop call-pinning; leadership now only dedupes general presence - useSignallingService: key presence by session; broadcast when leader OR in a call; dedupe channel/call getters by DID - webrtcStore: session-keyed peer connections, session-precise WebRTC signalling, connect to own other sessions, remove the error toast - call UI: per-session tiles + audio-dedupe mute path - add pure callSessions helper module with unit tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2f4928 commit 6cde515

9 files changed

Lines changed: 642 additions & 260 deletions

File tree

app/src/components/call/composables/useVideoLayout.ts

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import {
77
VideoLayoutOption,
88
type MediaState,
99
} from '@/stores';
10+
import { deriveRemoteSessionTiles, type RemoteCallSession } from '@/utils/callSessions';
11+
import { useTabCoordinator } from '@/composables/useTabCoordinator';
1012
import { storeToRefs } from 'pinia';
1113
import { computed, watch } from 'vue';
1214

1315
export type Participant = {
1416
isMe: boolean;
1517
did: string;
18+
// The session this tile belongs to. One agent can be in the call from several
19+
// sessions (tabs/devices); each session's video/screenshare is its own tile.
20+
sessionId: string;
1621
inCall: boolean;
1722
stream: MediaStream | undefined;
1823
streamReady: boolean;
@@ -24,6 +29,9 @@ export type Participant = {
2429
// can be rendered as two participants when they're sharing their screen
2530
// alongside their webcam.
2631
streamKind: 'camera' | 'screenshare';
32+
// Audio dedupe: when one person is in the call from multiple sessions, only
33+
// one of their tiles plays audio so the others don't echo.
34+
muteAudio: boolean;
2735
};
2836

2937
export function useVideoLayout() {
@@ -38,6 +46,8 @@ export function useVideoLayout() {
3846
storeToRefs(uiStore);
3947
const { inCall, peerConnections, disconnectedAgents } = storeToRefs(webrtcStore);
4048

49+
const mySessionId = useTabCoordinator().tabId;
50+
4151
const videoLayoutOptions: VideoLayoutOption[] = [
4252
{ label: '16/9 aspect ratio', class: '16-by-9', icon: 'aspect-ratio' },
4353
{ label: 'Flexible aspect ratio', class: 'flexible', icon: 'arrows-fullscreen' },
@@ -62,6 +72,7 @@ export function useVideoLayout() {
6272
myParticipants.push({
6373
isMe: true,
6474
did: me.value.did,
75+
sessionId: mySessionId,
6576
inCall: inCall.value,
6677
stream: stream.value || undefined,
6778
streamReady: true,
@@ -72,6 +83,7 @@ export function useVideoLayout() {
7283
screenShareState: 'off' as MediaState,
7384
warning,
7485
streamKind: 'camera',
86+
muteAudio: false, // the local tile is always muted via `isMe`
7587
});
7688
}
7789

@@ -84,6 +96,7 @@ export function useVideoLayout() {
8496
myParticipants.push({
8597
isMe: true,
8698
did: me.value.did,
99+
sessionId: mySessionId,
87100
inCall: inCall.value,
88101
stream: screenShareStream.value,
89102
streamReady: true,
@@ -92,59 +105,48 @@ export function useVideoLayout() {
92105
screenShareState: 'on' as MediaState,
93106
warning: '' as MediaPlayerWarning,
94107
streamKind: 'screenshare',
108+
muteAudio: false,
95109
});
96110
}
97111

98-
const otherAgents: Participant[] = peers.value.flatMap((peer) => {
99-
const streams = peer.streams ?? [];
100-
101-
// Treat a stream as a screenshare when it carries video without audio.
102-
// The camera stream always has at least one audio track (mic), so this
103-
// distinguishes the two without needing extra signalling. Falls back
104-
// to the legacy single-stream rendering when only one stream exists.
105-
const cameraStream = streams.find((s) => s.getAudioTracks().length > 0) ?? streams[0];
106-
const screenShareStreams = streams.filter(
107-
(s) => s !== cameraStream && s.getVideoTracks().length > 0,
108-
);
109-
110-
const cameraEntry: Participant = {
111-
isMe: false,
112-
did: peer.did,
113-
inCall: true,
114-
stream: cameraStream || undefined,
115-
streamReady: peer.streamReady,
116-
audioState: peer.audioState,
117-
videoState: peer.videoState,
118-
screenShareState: screenShareStreams.length > 0 ? 'off' : peer.screenShareState,
119-
warning: '' as MediaPlayerWarning,
120-
streamKind: 'camera',
121-
};
122-
123-
const screenShareEntries: Participant[] = screenShareStreams.map((screenStream) => ({
124-
isMe: false,
125-
did: peer.did,
126-
inCall: true,
127-
stream: screenStream,
128-
streamReady: peer.streamReady,
129-
audioState: 'off' as MediaState,
130-
videoState: 'off' as MediaState,
131-
screenShareState: 'on' as MediaState,
132-
warning: '' as MediaPlayerWarning,
133-
streamKind: 'screenshare',
134-
}));
135-
136-
return [cameraEntry, ...screenShareEntries];
137-
});
112+
// Remote peers are session-level (one peer connection per session). Derive
113+
// render tiles via the shared helper: every video / screenshare feed shows
114+
// separately, no-video sessions collapse to one avatar per person, and only
115+
// one tile per person carries audio.
116+
const remoteSessions: RemoteCallSession<MediaStream>[] = peers.value.map((peer) => ({
117+
did: peer.did,
118+
sessionId: peer.sessionId,
119+
streams: peer.streams ?? [],
120+
streamReady: peer.streamReady,
121+
audioState: peer.audioState,
122+
videoState: peer.videoState,
123+
screenShareState: peer.screenShareState,
124+
}));
125+
126+
const otherAgents: Participant[] = deriveRemoteSessionTiles(remoteSessions).map((tile) => ({
127+
isMe: false,
128+
did: tile.did,
129+
sessionId: tile.sessionId,
130+
inCall: true,
131+
stream: tile.stream || undefined,
132+
streamReady: tile.streamReady,
133+
audioState: tile.audioState,
134+
videoState: tile.videoState,
135+
screenShareState: tile.screenShareState,
136+
warning: '' as MediaPlayerWarning,
137+
streamKind: tile.streamKind,
138+
muteAudio: tile.muteAudio,
139+
}));
138140

139141
return [...myParticipants, ...otherAgents];
140142
});
141143

142-
// Participants may now share a `did` (a camera tile + a screenshare tile
143-
// for the same user), so the focus key is the composite `did:streamKind`.
144-
// Plain DIDs from older state still match — they fall through to the
145-
// first tile we find for that user, which is the camera tile.
144+
// A `did` can now map to several tiles — multiple sessions (tabs/devices),
145+
// each with a camera and/or screenshare tile — so the key is the composite
146+
// `did:sessionId:streamKind`. Plain DIDs from older state still match via
147+
// `matchesFocus`, falling through to the first tile for that user.
146148
function participantKey(p: Participant): string {
147-
return `${p.did}:${p.streamKind}`;
149+
return `${p.did}:${p.sessionId}:${p.streamKind}`;
148150
}
149151

150152
function matchesFocus(p: Participant, focusedId: string): boolean {
@@ -196,13 +198,19 @@ export function useVideoLayout() {
196198
if (open) closeFocusedVideoLayout();
197199
});
198200

199-
// Toggle off focused layout if the focused agent disconnects
201+
// Toggle off focused layout if the focused session disconnects.
202+
// `disconnectedAgents` holds session keys (`did::sessionId`); the focused id
203+
// is a participant key (`did:sessionId:streamKind`) or a bare DID, so match by
204+
// the focused tile's actual session.
200205
watch(
201206
disconnectedAgents,
202207
(disconnected) => {
203-
if (focusedVideoId.value && disconnected.includes(focusedVideoId.value)) {
204-
closeFocusedVideoLayout();
205-
}
208+
if (!focusedVideoId.value) return;
209+
const focused = focusedParticipant.value;
210+
const focusedDisconnected =
211+
disconnected.includes(focusedVideoId.value) ||
212+
(!!focused && disconnected.includes(`${focused.did}::${focused.sessionId}`));
213+
if (focusedDisconnected) closeFocusedVideoLayout();
206214
},
207215
{ deep: true },
208216
);

app/src/components/call/window/VideoGrid.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
:key="`participant-${participantKey(participant)}`"
2626
:did="participant.did"
2727
:isMe="participant.isMe"
28+
:muteAudio="participant.muteAudio"
2829
:inCall="participant.inCall"
2930
:stream="participant.stream"
3031
:streamReady="participant.streamReady"
@@ -44,6 +45,7 @@
4445
:key="`participant-${participantKey(focusedParticipant)}`"
4546
:did="focusedParticipant.did"
4647
:isMe="focusedParticipant.isMe"
48+
:muteAudio="focusedParticipant.muteAudio"
4749
:inCall="focusedParticipant.inCall"
4850
:stream="focusedParticipant.stream"
4951
:streamReady="focusedParticipant.streamReady"
@@ -63,6 +65,7 @@
6365
:key="`participant-${participantKey(participant)}`"
6466
:did="participant.did"
6567
:isMe="participant.isMe"
68+
:muteAudio="participant.muteAudio"
6669
:inCall="participant.inCall"
6770
:stream="participant.stream"
6871
:streamReady="participant.streamReady"

app/src/components/media-player/MediaPlayer.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<video
44
ref="videoElement"
55
class="video"
6-
:muted="isMe"
6+
:muted="isMe || muteAudio"
77
:style="{ opacity: showVideo ? 1 : 0, transform: flipVideo ? 'scaleX(-1)' : 'none' }"
88
autoplay
99
playsinline
@@ -82,6 +82,9 @@ export type MediaPlayerWarning = '' | 'mic-disabled' | 'camera-disabled';
8282
const props = defineProps({
8383
did: { type: String, default: '' },
8484
isMe: { type: Boolean, default: false },
85+
// Mutes this tile's audio without hiding it — used to dedupe a person who is
86+
// in the call from multiple sessions so their devices don't echo each other.
87+
muteAudio: { type: Boolean, default: false },
8588
inCall: { type: Boolean, default: false },
8689
stream: { type: MediaStream, default: null },
8790
streamReady: { type: Boolean, default: false },

0 commit comments

Comments
 (0)