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 @@