Skip to content

Commit 02174d7

Browse files
authored
fix(meeting): layout flicker and stable sorting (#24)
* test(fake-user): use fake rtp connections for video and audio * fix(meeting): layout flicker and stable sorting - don't jump around if they're swapped bw visible tiles - wait a bit before putting active speaker to visible tile - no more flickers that happened due to broken animations and rerenderes
1 parent adeacbd commit 02174d7

12 files changed

Lines changed: 699 additions & 373 deletions

frontend/src/components/ScreenShareSidebar.vue

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
<TransitionGroup
33
name="tile"
44
tag="div"
5-
class="relative overflow-y-auto p-1 grid gap-2 h-full"
5+
class="relative overflow-y-auto grid gap-2 h-full"
66
:class="sidebarClass"
77
:style="sidebarStyle"
8-
@before-leave="lockTileDimensions"
98
>
109
<!-- Local camera tile -->
1110
<div
@@ -51,10 +50,11 @@
5150

5251
<!-- Remote participants -->
5352
<ScreenShareSidebarParticipantTile
54-
v-for="participant in sidebarDisplay.list"
53+
v-for="participant in allParticipants"
5554
:key="'side-' + participant.user_id"
5655
:participant="participant"
57-
:videoRef="handleRemoteVideoRef"
56+
:class="{ 'hidden-tile': !participant.isVisible }"
57+
:videoRef="getRemoteVideoRef(participant.user_id)"
5858
:tileStyle="singleTileStyle"
5959
:visibleTileCount="visibleTileCount"
6060
/>
@@ -90,31 +90,26 @@ const setLocalVideoRef = inject("setLocalVideoRef");
9090
const setRemoteVideoRef = inject("setRemoteVideoRef");
9191
const { registerTile } = useTileAdaptiveStreaming();
9292
93-
const handleRemoteVideoRef = (participantId, el) => {
94-
setRemoteVideoRef(participantId, el);
95-
registerTile(participantId, el);
96-
};
93+
const videoRefHandlers = new Map();
9794
98-
// needed to avoid layout shifts during tile removal
99-
const lockTileDimensions = (el) => {
100-
const width = el.offsetWidth;
101-
const height = el.offsetHeight;
102-
const top = el.offsetTop;
103-
const left = el.offsetLeft;
104-
105-
el.style.position = "absolute";
106-
el.style.width = `${width}px`;
107-
el.style.height = `${height}px`;
108-
el.style.top = `${top}px`;
109-
el.style.left = `${left}px`;
110-
el.style.pointerEvents = "none";
95+
// cache ref handlers to avoid UI flicker
96+
const getRemoteVideoRef = (participantId) => {
97+
if (!videoRefHandlers.has(participantId)) {
98+
videoRefHandlers.set(participantId, (el) => {
99+
setRemoteVideoRef(participantId, el);
100+
registerTile(participantId, el);
101+
});
102+
}
103+
return videoRefHandlers.get(participantId);
111104
};
112105
113106
const participants = computed(() => meetingState.participants.value);
114107
const currentUser = computed(() => meetingState.currentUser.value);
115108
const isCameraOn = computed(() => meetingState.isCameraOn.value);
116109
const isMicOn = computed(() => meetingState.isMicOn.value);
117-
const activeSpeakerIds = computed(() => meetingState.activeSpeakerIds.value);
110+
const stableSpeakerIds = computed(
111+
() => meetingState.stableSpeakerIds?.value || [],
112+
);
118113
119114
const userInitials = computed(() => {
120115
const name = currentUser.value?.full_name || currentUser.value?.name || "You";
@@ -130,12 +125,13 @@ const userAvatar = computed(() => currentUser.value?.avatar || "");
130125
131126
const {
132127
sidebarDisplay,
128+
allParticipants,
133129
sidebarClass,
134130
sidebarStyle,
135131
singleTileStyle,
136132
visibleTileCount,
137133
hiddenParticipantsTooltip,
138-
} = useScreenShareSidebar(participants, activeSpeakerIds, meetingState);
134+
} = useScreenShareSidebar(participants, stableSpeakerIds, meetingState);
139135
140136
const { stream: localStream } = useAudioStream(currentUser.value?.user_id);
141137
@@ -145,7 +141,15 @@ const handleOpenPeoplePanel = () => {
145141
</script>
146142
147143
<style scoped>
148-
/* Transition styles */
144+
.hidden-tile {
145+
position: absolute;
146+
opacity: 0;
147+
pointer-events: none;
148+
transform: scale(0);
149+
bottom: 0;
150+
right: 0;
151+
z-index: 0;
152+
}
149153
.tile-enter-from,
150154
.tile-leave-to {
151155
opacity: 0;
@@ -165,11 +169,6 @@ const handleOpenPeoplePanel = () => {
165169
166170
.tile-leave-active {
167171
position: absolute;
168-
top: 0;
169-
left: 0;
170-
width: 100%;
171-
height: 100%;
172-
pointer-events: none;
173172
}
174173
175174
.remote-video {

frontend/src/components/ScreenShareSidebarParticipantTile.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
:style="tileStyle"
55
>
66
<video
7-
:ref="(el) => videoRef(participant.user_id, el)"
7+
:ref="videoRef"
88
:participant-id="participant.user_id"
99
class="w-full h-full object-cover flex-1 remote-video"
1010
autoplay

frontend/src/components/VideoGrid.vue

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<TransitionGroup
44
name="tile"
55
tag="div"
6-
class="h-full grid gap-2 call-grid transition-all duration-300 ease-out"
6+
class="h-full grid gap-2 call-grid"
77
:class="gridClass"
88
:style="gridStyle"
99
>
@@ -21,14 +21,15 @@
2121

2222
<!-- Remote participants -->
2323
<ParticipantTile
24-
v-for="participant in displayParticipants.list"
25-
:key="'grid-' + participant.user_id"
24+
v-for="participant in allParticipants"
25+
:class="{ 'hidden-tile': !participant.isVisible }"
26+
:key="'tile-' + participant.user_id"
2627
:participant="participant"
2728
:isLocal="false"
2829
:isVideoEnabled="participant.video_enabled"
2930
:isAudioEnabled="participant.audio_enabled"
3031
:isActiveSpeaker="activeSpeakerIds.includes(participant.user_id)"
31-
:videoRef="(el) => handleRemoteVideoRef(participant.user_id, el)"
32+
:videoRef="getRemoteVideoRef(participant.user_id)"
3233
:tileCount="visibleTileCount"
3334
/>
3435

@@ -77,6 +78,18 @@ const handleRemoteVideoRef = (participantId, el) => {
7778
registerTile(participantId, el);
7879
};
7980
81+
const videoRefHandlers = new Map();
82+
83+
// cache ref handlers to avoid UI flicker
84+
const getRemoteVideoRef = (participantId) => {
85+
if (!videoRefHandlers.has(participantId)) {
86+
videoRefHandlers.set(participantId, (el) => {
87+
handleRemoteVideoRef(participantId, el);
88+
});
89+
}
90+
return videoRefHandlers.get(participantId);
91+
};
92+
8093
const gridContainer = ref(null);
8194
8295
const participants = computed(() => meetingState.participants.value);
@@ -119,11 +132,12 @@ const localParticipant = computed(() => {
119132
120133
const {
121134
displayParticipants,
135+
allParticipants,
122136
gridClass,
123137
gridStyle,
124138
visibleTileCount,
125139
hiddenParticipantsTooltip,
126-
} = useVideoGridLayout(participants, activeSpeakerIds, meetingState);
140+
} = useVideoGridLayout(participants, meetingState);
127141
128142
const hiddenParticipantReactions = computed(() => {
129143
const reactions = meetingState.reactions?.value || {};
@@ -162,3 +176,37 @@ const hiddenParticipantReactions = computed(() => {
162176
return sorted.slice(0, 6);
163177
});
164178
</script>
179+
180+
<style scoped>
181+
/* Hidden tiles are kept mounted to not affect grid layout animations. */
182+
.hidden-tile {
183+
position: absolute;
184+
opacity: 0;
185+
pointer-events: none;
186+
transform: scale(0);
187+
bottom: 0;
188+
right: 0;
189+
z-index: 0;
190+
}
191+
192+
/* Animation styles */
193+
.tile-enter-from,
194+
.tile-leave-to {
195+
opacity: 0;
196+
transform: scale(0.85);
197+
}
198+
199+
.tile-enter-active,
200+
.tile-leave-active {
201+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
202+
}
203+
204+
.tile-move {
205+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
206+
}
207+
208+
.tile-leave-active {
209+
position: absolute;
210+
z-index: 0;
211+
}
212+
</style>

frontend/src/composables/useMeetingLogic.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function useMeetingLogic(meetingState, meetingId, options = {}) {
7373
const { applyNoiseCancellation } = useNoiseCancellation();
7474

7575
let noiseCancellationSession = null;
76+
let stabilityCheckTimeout = null;
7677

7778
const replacePublishedVideoTrack = async (
7879
stream,
@@ -1570,9 +1571,72 @@ export function useMeetingLogic(meetingState, meetingId, options = {}) {
15701571
clearTimeout(activeSpeakerTimeout.value);
15711572
activeSpeakerTimeout.value = null;
15721573
}
1574+
if (stabilityCheckTimeout) {
1575+
clearTimeout(stabilityCheckTimeout);
1576+
stabilityCheckTimeout = null;
1577+
}
15731578

15741579
meetingState.activeSpeakerIds.value = participantIds;
15751580

1581+
const STABLE_THRESHOLD_MS = 1000;
1582+
const DEMOTE_THRESHOLD_MS = 3000;
1583+
1584+
const checkStability = () => {
1585+
const now = Date.now();
1586+
const currentSet = new Set(meetingState.activeSpeakerIds.value);
1587+
const startTimes = { ...meetingState.speakerStartTimes.value };
1588+
const currentStable = new Set(
1589+
meetingState.stableSpeakerIds.value || [],
1590+
);
1591+
let hasPendingCandidates = false;
1592+
1593+
// 1. Handle stopped speakers (demotion logic)
1594+
for (const id of Object.keys(startTimes)) {
1595+
if (!currentSet.has(id)) {
1596+
if (startTimes[id] > 0) {
1597+
startTimes[id] = -now; // Mark stopped
1598+
} else if (now - Math.abs(startTimes[id]) > DEMOTE_THRESHOLD_MS) {
1599+
delete startTimes[id];
1600+
currentStable.delete(id);
1601+
}
1602+
} else if (startTimes[id] < 0) {
1603+
startTimes[id] = now; // Resume
1604+
}
1605+
}
1606+
1607+
// 2. Add new speakers
1608+
for (const id of currentSet) {
1609+
if (startTimes[id] === undefined) {
1610+
startTimes[id] = now;
1611+
}
1612+
}
1613+
1614+
// 3. Promote stable speakers
1615+
for (const id of currentSet) {
1616+
const startTime = startTimes[id];
1617+
if (startTime > 0) {
1618+
if (now - startTime >= STABLE_THRESHOLD_MS) {
1619+
currentStable.add(id);
1620+
} else {
1621+
hasPendingCandidates = true;
1622+
}
1623+
}
1624+
}
1625+
1626+
meetingState.speakerStartTimes.value = startTimes;
1627+
meetingState.stableSpeakerIds.value = Array.from(currentStable);
1628+
1629+
// Schedule re-check if we have candidates waiting to become stable
1630+
if (hasPendingCandidates) {
1631+
if (stabilityCheckTimeout) clearTimeout(stabilityCheckTimeout);
1632+
stabilityCheckTimeout = setTimeout(checkStability, 200);
1633+
} else {
1634+
stabilityCheckTimeout = null;
1635+
}
1636+
};
1637+
1638+
checkStability();
1639+
15761640
if (participantIds.length > 0) {
15771641
activeSpeakerTimeout.value = setTimeout(() => {
15781642
meetingState.activeSpeakerIds.value = [];
@@ -2120,6 +2184,10 @@ export function useMeetingLogic(meetingState, meetingId, options = {}) {
21202184
clearTimeout(activeSpeakerTimeout.value);
21212185
activeSpeakerTimeout.value = null;
21222186
}
2187+
if (stabilityCheckTimeout) {
2188+
clearTimeout(stabilityCheckTimeout);
2189+
stabilityCheckTimeout = null;
2190+
}
21232191

21242192
// Cleanup SFU manager (disconnect and free resources)
21252193
sfuManager.value?.cleanup?.();

frontend/src/composables/useMeetingState.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export function useMeetingState() {
4141
const participants = ref({});
4242
const remoteVideos = ref({});
4343
const activeSpeakerIds = ref([]);
44+
const stableSpeakerIds = ref([]);
45+
const speakerStartTimes = ref({});
4446

4547
// Waiting room states
4648
const isWaitingForApproval = ref(false);
@@ -138,6 +140,8 @@ export function useMeetingState() {
138140
localStream.value = null;
139141
cameraPermissionGranted.value = false;
140142
microphonePermissionGranted.value = false;
143+
stableSpeakerIds.value = [];
144+
speakerStartTimes.value = {};
141145
};
142146

143147
const setMediaState = (mic, camera) => {
@@ -197,6 +201,8 @@ export function useMeetingState() {
197201
participants,
198202
remoteVideos,
199203
activeSpeakerIds,
204+
stableSpeakerIds,
205+
speakerStartTimes,
200206
isWaitingForApproval,
201207
isJoinRequestRejected,
202208
waitingUsers,

0 commit comments

Comments
 (0)