Skip to content

Commit f2f4928

Browse files
committed
fix(call): fullscreen video layout — auto-fit, centre last row, self-view toggle, camera + screenshare coexist
Four related issues with the in-call video grid landed in one PR because each one was a slice of the same per-participant layout pipeline: 1. **Tiles overflowed in fullscreen.** The grid used `grid-auto-rows: min-content` and capped each tile via `max-height: 100%`, but `100%` of an overflowing parent is itself the overflowing height, so the trailing rows scrolled off-screen. Added a `fullscreen` class on `.video-grid` that switches to `grid-template-rows: repeat(var(--number-of-rows), 1fr)` with `overflow: hidden` and per-tile `align/justify-self: center`, so every tile fits the available space while preserving the 16/9 aspect ratio. 2. **Odd-numbered trailing rows were left-aligned.** Added a computed `lastRowTiles` count and emit a `grid-column-start: <offset+1>` style on the first tile of an incomplete trailing row. Subsequent tiles flow naturally; no per-row wrapper or `:has(...)` selector required. 3. **Self-view couldn't be hidden.** Added `selfViewVisible` / `toggleSelfViewVisible` on the UI store (persisted across sessions) and a desktop-only "Hide my video / Show my video" button in `MainCallControls.vue`. When off, the local camera tile is dropped from `allParticipants` — the user remains in the call and is still heard, the local screenshare tile (when present) is still shown so the user can verify what they're broadcasting. 4. **Screenshare overrode the user's camera tile on both sides of the call.** Three connected changes: - `mediaDevicesStore.turnOnScreenShare` no longer removes the camera track from `stream`. The screenshare is captured into its own `screenShareStream` ref and sent to peers via a new `webrtcStore.addScreenShareTrack` (a `peer.addTrack(track, dedicatedStream)` instead of a `replaceTrack` swap), so the receiving side fires a fresh `peer.on('track')` event with a distinct stream id. - `webrtcStore.ts` peer.on('track') handler used to wipe the per-peer `streams` array on every new stream (`streams = [stream]`), which silently dropped the camera tile the moment a screenshare track arrived. Now appends a new stream entry and updates in place when extra tracks land on an existing stream. Tracks that end clear their stream from the array. - `useVideoLayout.allParticipants` splits both the local user and each remote peer into separate camera-tile / screenshare-tile entries (`streamKind: 'camera' | 'screenshare'`). Remote screenshares are identified as streams with video tracks but no audio tracks — the camera stream always carries the mic track. - Cleaned up the old `savedVideoTrack` swap-back bookkeeping in `toggleVideo` / `resetMediaDevices` — superfluous now that camera and screenshare run on independent streams. Composite `did:streamKind` participant keys replace bare DIDs in `focusOnVideo` / focused-layout, so the focus model handles the camera-vs-screenshare split too. Bare DIDs from older persisted state still match (first tile for that user, which is the camera tile).
1 parent bc3bae2 commit f2f4928

6 files changed

Lines changed: 355 additions & 108 deletions

File tree

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

Lines changed: 122 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,32 @@ import {
1010
import { storeToRefs } from 'pinia';
1111
import { computed, watch } from 'vue';
1212

13+
export type Participant = {
14+
isMe: boolean;
15+
did: string;
16+
inCall: boolean;
17+
stream: MediaStream | undefined;
18+
streamReady: boolean;
19+
audioState: MediaState;
20+
videoState: MediaState;
21+
screenShareState: MediaState;
22+
warning: MediaPlayerWarning;
23+
// Distinguishes a camera tile from a screenshare tile so a single user
24+
// can be rendered as two participants when they're sharing their screen
25+
// alongside their webcam.
26+
streamKind: 'camera' | 'screenshare';
27+
};
28+
1329
export function useVideoLayout() {
1430
const appStore = useAppStore();
1531
const mediaDeviceStore = useMediaDevicesStore();
1632
const uiStore = useUiStore();
1733
const webrtcStore = useWebrtcStore();
1834

1935
const { me } = storeToRefs(appStore);
20-
const { stream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
21-
const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId } = storeToRefs(uiStore);
36+
const { stream, screenShareStream, mediaSettings, mediaPermissions } = storeToRefs(mediaDeviceStore);
37+
const { callWindowWidth, callWindowOpen, selectedVideoLayout, focusedVideoId, selfViewVisible } =
38+
storeToRefs(uiStore);
2239
const { inCall, peerConnections, disconnectedAgents } = storeToRefs(webrtcStore);
2340

2441
const videoLayoutOptions: VideoLayoutOption[] = [
@@ -37,41 +54,112 @@ export function useVideoLayout() {
3754
if (microphone && microphone.requested && !microphone.granted) warning = 'mic-disabled';
3855
else if (videoEnabled && camera && camera.requested && !camera.granted) warning = 'camera-disabled';
3956

40-
const myAgent = {
41-
isMe: true,
42-
did: me.value.did,
43-
inCall: inCall.value,
44-
stream: stream.value || undefined,
45-
streamReady: true,
46-
audioState: (audioEnabled ? 'on' : 'off') as MediaState,
47-
videoState: (videoEnabled ? 'on' : 'off') as MediaState,
48-
screenShareState: (screenShareEnabled ? 'on' : 'off') as MediaState,
49-
warning,
50-
};
51-
52-
const otherAgents = peers.value.map((peer) => ({
53-
isMe: false,
54-
did: peer.did,
55-
inCall: true,
56-
stream: peer.streams?.[0] || undefined,
57-
streamReady: peer.streamReady,
58-
audioState: peer.audioState,
59-
videoState: peer.videoState,
60-
screenShareState: peer.screenShareState,
61-
warning: '' as MediaPlayerWarning,
62-
}));
63-
64-
return [myAgent, ...otherAgents];
57+
const myParticipants: Participant[] = [];
58+
59+
// Local camera tile. Hidden when the user toggles self-view off so they
60+
// can focus on the other participants while still being in the call.
61+
if (selfViewVisible.value) {
62+
myParticipants.push({
63+
isMe: true,
64+
did: me.value.did,
65+
inCall: inCall.value,
66+
stream: stream.value || undefined,
67+
streamReady: true,
68+
audioState: (audioEnabled ? 'on' : 'off') as MediaState,
69+
videoState: (videoEnabled ? 'on' : 'off') as MediaState,
70+
// Camera tile never shows the screenshare badge — when both are on
71+
// we emit a separate screenshare tile below.
72+
screenShareState: 'off' as MediaState,
73+
warning,
74+
streamKind: 'camera',
75+
});
76+
}
77+
78+
// Local screenshare tile. Emitted as its own participant so the camera
79+
// and screenshare can be rendered side-by-side instead of one replacing
80+
// the other. Always shown to the local user (even when self-view is
81+
// off) because hiding your own screenshare would make it impossible to
82+
// verify what you're broadcasting.
83+
if (screenShareEnabled && screenShareStream.value) {
84+
myParticipants.push({
85+
isMe: true,
86+
did: me.value.did,
87+
inCall: inCall.value,
88+
stream: screenShareStream.value,
89+
streamReady: true,
90+
audioState: 'off' as MediaState,
91+
videoState: 'off' as MediaState,
92+
screenShareState: 'on' as MediaState,
93+
warning: '' as MediaPlayerWarning,
94+
streamKind: 'screenshare',
95+
});
96+
}
97+
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+
});
138+
139+
return [...myParticipants, ...otherAgents];
65140
});
66141

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.
146+
function participantKey(p: Participant): string {
147+
return `${p.did}:${p.streamKind}`;
148+
}
149+
150+
function matchesFocus(p: Participant, focusedId: string): boolean {
151+
if (!focusedId) return false;
152+
return participantKey(p) === focusedId || p.did === focusedId;
153+
}
154+
67155
const focusedParticipant = computed(() => {
68156
const focusedId = focusedVideoId.value || me.value.did;
69-
return allParticipants.value.find((p) => p.did === focusedId) || allParticipants.value[0];
157+
return allParticipants.value.find((p) => matchesFocus(p, focusedId)) || allParticipants.value[0];
70158
});
71159

72160
const unfocusedParticipants = computed(() => {
73161
const focusedId = focusedVideoId.value || me.value.did;
74-
return allParticipants.value.filter((p) => p.did !== focusedId);
162+
return allParticipants.value.filter((p) => !matchesFocus(p, focusedId));
75163
});
76164

77165
const numberOfColumns = computed(() => {
@@ -88,9 +176,11 @@ export function useVideoLayout() {
88176
uiStore.setVideoLayout(layout);
89177
}
90178

91-
function focusOnVideo(did: string) {
179+
function focusOnVideo(key: string) {
92180
if (!inCall.value) return;
93-
uiStore.setFocusedVideoId(did);
181+
// `key` is either a participant key (`did:streamKind`) or a bare DID
182+
// from older callers; both are honoured by `matchesFocus` above.
183+
uiStore.setFocusedVideoId(key);
94184
if (selectedVideoLayout.value.label !== 'Focused') {
95185
uiStore.setVideoLayout(videoLayoutOptions[2]);
96186
}
@@ -133,5 +223,6 @@ export function useVideoLayout() {
133223
selectVideoLayout,
134224
focusOnVideo,
135225
closeFocusedVideoLayout,
226+
participantKey,
136227
};
137228
}

app/src/components/call/controls/MainCallControls.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@
7171
</j-button>
7272
</j-tooltip>
7373

74+
<j-tooltip
75+
v-if="!isMobile && inCall"
76+
placement="top"
77+
:title="selfViewVisible ? 'Hide my video' : 'Show my video'"
78+
>
79+
<j-button
80+
:variant="selfViewVisible ? '' : 'primary'"
81+
@click="uiStore.toggleSelfViewVisible"
82+
square
83+
circle
84+
:size="isMobile ? 'md' : 'lg'"
85+
>
86+
<j-icon :name="selfViewVisible ? 'eye-slash' : 'eye'" :size="isMobile ? 'sm' : 'md'" />
87+
</j-button>
88+
</j-tooltip>
89+
7490
<j-popover v-if="!isMobile" ref="videoLayoutPopover" placement="top">
7591
<j-tooltip slot="trigger" placement="top" title="Video layout options">
7692
<j-button variant="transparent" square circle :disabled="!inCall" :size="isMobile ? 'md' : 'lg'">
@@ -153,7 +169,7 @@ const modalStore = useModalStore();
153169
const aiStore = useAiStore();
154170
155171
const { me } = storeToRefs(appStore);
156-
const { callWindowFullscreen, isMobile, isLandscapeMobile } = storeToRefs(uiStore);
172+
const { callWindowFullscreen, isMobile, isLandscapeMobile, selfViewVisible } = storeToRefs(uiStore);
157173
const { mediaSettings, availableDevices } = storeToRefs(mediaDeviceStore);
158174
const { transcriptionEnabled } = storeToRefs(aiStore);
159175
const { inCall, hasCopiedLink } = storeToRefs(webrtcStore);

0 commit comments

Comments
 (0)