@@ -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' ;
1012import { storeToRefs } from 'pinia' ;
1113import { computed , watch } from 'vue' ;
1214
1315export 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
2937export 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 ) ;
0 commit comments