Improve conference attendee controls and stage participant strip.
Deploy Ladill Meet / deploy (push) Successful in 58s

Give attendees reaction buttons instead of media controls, gate applause audio until multiple people clap, show the avatar strip in panel layout, and remove the plenary stage subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 23:46:54 +00:00
co-authored by Cursor
parent f812a92cf9
commit e698e75f17
4 changed files with 195 additions and 23 deletions
+7 -2
View File
@@ -136,11 +136,16 @@ html.meet-room-page {
border-top: 0;
}
.meet-room-page .meet-stage--panel #meet-stage-spotlight,
.meet-room-page .meet-stage--panel #meet-audience-strip {
.meet-room-page .meet-stage--panel #meet-stage-spotlight {
display: none;
}
.meet-room-page .meet-stage--panel .meet-stage-panel-grid {
flex: 1 1 0;
min-height: 0;
height: auto;
}
.meet-room-page .meet-stage-panel-grid {
display: grid;
height: 100%;
+154 -13
View File
@@ -19,6 +19,15 @@ function meetRoom() {
let initialized = false;
let recordingMediaRecorder = null;
let recordingChunks = [];
let seenReactionKeys = new Set();
let applauseClapTimes = [];
let applauseAudio = null;
let lastApplauseSoundAt = 0;
const APPLAUSE_WINDOW_MS = 4000;
const APPLAUSE_MIN_CLAPPERS = 2;
const APPLAUSE_SOUND_COOLDOWN_MS = 3000;
const APPLAUSE_EMOJIS = new Set(['👍', '👏', '🙌']);
return {
isMuted: true,
@@ -77,6 +86,7 @@ function meetRoom() {
activeSpeakerIds: [],
roomHost: null,
floatingReactions: [],
reactionsInitialized: false,
pollTimer: null,
config: {},
@@ -120,6 +130,8 @@ function meetRoom() {
this.passcode = el.dataset.passcode || '';
this.roomTitle = el.dataset.roomTitle || '';
this.initApplauseAudio(el.dataset.applauseAudio || '');
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
@@ -686,16 +698,40 @@ function meetRoom() {
return this.inCallParticipants()
.filter((person) => person.uuid !== spotlightUuid)
.sort((a, b) => {
const aSpeaker = this.isSpeakerPerson(a);
const bSpeaker = this.isSpeakerPerson(b);
.sort((a, b) => this.compareStripParticipants(a, b));
},
if (aSpeaker !== bSpeaker) {
return aSpeaker ? -1 : 1;
panelStripParticipants() {
const onPanelRefs = new Set(this.getActivePanelSpeakerRefs());
return this.inCallParticipants()
.filter((person) => {
if (!this.isSpeakerPerson(person)) {
return true;
}
return a.display_name.localeCompare(b.display_name);
});
return !person.user_ref || !onPanelRefs.has(person.user_ref);
})
.sort((a, b) => this.compareStripParticipants(a, b));
},
compareStripParticipants(a, b) {
const aSpeaker = this.isSpeakerPerson(a);
const bSpeaker = this.isSpeakerPerson(b);
if (aSpeaker !== bSpeaker) {
return aSpeaker ? -1 : 1;
}
return a.display_name.localeCompare(b.display_name);
},
stripParticipants() {
if (this.stageLayout === 'panel') {
return this.panelStripParticipants();
}
return this.plenaryStripParticipants();
},
createStripInitials(person) {
@@ -841,11 +877,11 @@ function meetRoom() {
strip.replaceChildren();
if (!this.usesStageLayout || this.stageLayout !== 'plenary') {
if (!this.usesStageLayout) {
return;
}
const audience = this.plenaryStripParticipants();
const audience = this.stripParticipants();
if (audience.length === 0) {
return;
}
@@ -1644,7 +1680,7 @@ function meetRoom() {
},
async toggleMute() {
if (!room) return;
if (!room || !this.canPublish) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isMicrophoneEnabled;
@@ -1671,7 +1707,7 @@ function meetRoom() {
},
async toggleVideo() {
if (!room || this.audioOnly) return;
if (!room || this.audioOnly || !this.canPublish) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled);
@@ -1690,7 +1726,7 @@ function meetRoom() {
},
async toggleScreenShare() {
if (!room || this.audioOnly) return;
if (!room || this.audioOnly || !this.canPublish) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
@@ -1844,6 +1880,10 @@ function meetRoom() {
body: JSON.stringify({ emoji }),
});
this.showFloatingReaction(kind);
if (kind === 'thumbs') {
this.registerApplauseClap(this.displayName);
}
},
async poll() {
@@ -1884,6 +1924,7 @@ function meetRoom() {
if (data.broadcasts?.length) {
this.breakoutBroadcast = data.broadcasts[0].body;
}
this.processReactionsFromPoll(data.reactions || []);
} catch (e) {
// ignore poll errors
}
@@ -1935,7 +1976,7 @@ function meetRoom() {
return 'Multiple speakers shown together in a split-screen grid.';
}
return 'One main speaker on stage. Others appear in the strip below.';
return '';
},
activePanelName() {
@@ -2045,6 +2086,106 @@ function meetRoom() {
}, 2000);
},
initApplauseAudio(url) {
if (!url) {
return;
}
applauseAudio = new Audio(url);
applauseAudio.preload = 'auto';
},
isApplauseEmoji(emoji) {
return APPLAUSE_EMOJIS.has(emoji);
},
registerApplauseClap(senderName) {
if (!senderName) {
return;
}
applauseClapTimes.push({ at: Date.now(), sender: senderName });
this.maybePlayApplauseSound();
},
maybePlayApplauseSound() {
if (!applauseAudio?.src) {
return;
}
const now = Date.now();
applauseClapTimes = applauseClapTimes.filter((entry) => now - entry.at <= APPLAUSE_WINDOW_MS);
const clappers = new Set(applauseClapTimes.map((entry) => entry.sender));
if (clappers.size < APPLAUSE_MIN_CLAPPERS) {
return;
}
if (now - lastApplauseSoundAt < APPLAUSE_SOUND_COOLDOWN_MS) {
return;
}
const volume = 0.35 + Math.min(clappers.size - APPLAUSE_MIN_CLAPPERS + 1, 4) * 0.12;
this.playApplauseSound(volume);
},
playApplauseSound(volume = 0.7) {
if (!applauseAudio?.src) {
return;
}
try {
applauseAudio.volume = Math.min(1, Math.max(0.2, volume));
applauseAudio.currentTime = 0;
applauseAudio.play().catch(() => {});
lastApplauseSoundAt = Date.now();
} catch {
// Browser blocked playback until user gesture.
}
},
processReactionsFromPoll(reactions) {
if (!Array.isArray(reactions)) {
return;
}
if (!this.reactionsInitialized) {
reactions.forEach((reaction) => {
seenReactionKeys.add(`${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`);
});
this.reactionsInitialized = true;
return;
}
reactions.forEach((reaction) => {
const key = `${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`;
if (seenReactionKeys.has(key)) {
return;
}
seenReactionKeys.add(key);
if (this.isApplauseEmoji(reaction.emoji)) {
if (reaction.sender_name !== this.displayName) {
this.showFloatingReaction('thumbs');
}
this.registerApplauseClap(reaction.sender_name);
return;
}
if (reaction.emoji === '❤️' && reaction.sender_name !== this.displayName) {
this.showFloatingReaction('heart');
}
});
if (seenReactionKeys.size > 200) {
seenReactionKeys = new Set([...seenReactionKeys].slice(-100));
}
},
headers() {
return {
'Content-Type': 'application/json',
+34 -8
View File
@@ -81,6 +81,8 @@
data-join-url="{{ $room->joinUrl() }}"
data-room-title="{{ $room->title }}"
data-passcode="{{ $room->passcode ?? '' }}"
@php $applauseAudioVer = @filemtime(public_path('audios/clapping.wav')) ?: null; @endphp
data-applause-audio="{{ $applauseAudioVer ? asset('audios/clapping.wav') . '?v=' . $applauseAudioVer : '' }}"
data-csrf="{{ csrf_token() }}"
data-session-uuid="{{ $session->uuid }}"
data-initial-participants='@json($joinedParticipants)'
@@ -175,7 +177,7 @@
<p class="text-sm font-semibold"
:class="stageLayout === 'panel' ? 'text-violet-100' : 'text-sky-100'"
x-text="stageLayoutLabel()"></p>
<p class="truncate text-xs"
<p x-show="stageLayoutDescription()" class="truncate text-xs"
:class="stageLayout === 'panel' ? 'text-violet-200/85' : 'text-sky-200/85'"
x-text="stageLayoutDescription()"></p>
</div>
@@ -266,6 +268,7 @@
<div class="meet-toolbar__track sm:px-0">
<div class="meet-toolbar__primary sm:contents">
<button @click="toggleMute()" type="button"
x-show="canPublish"
class="rounded-full p-3 transition-colors"
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isMuted ? 'Unmute' : 'Mute'">
@@ -273,7 +276,7 @@
<svg x-show="isMuted" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleVideo()" type="button"
x-show="!audioOnly"
x-show="canPublish && !audioOnly"
class="rounded-full p-3 transition-colors"
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
@@ -281,12 +284,30 @@
<svg x-show="isVideoOff" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleScreenShare()" type="button"
x-show="!audioOnly"
x-show="canPublish && !audioOnly"
class="rounded-full p-3 transition-colors"
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Share screen">
@include('meet.room.partials.meet-icon', ['icon' => 'screen-share'])
</button>
<button @click="raiseHand()" type="button"
x-show="!canPublish"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="Raise hand">
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
</button>
<button @click="sendReaction('thumbs')" type="button"
x-show="!canPublish"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="Applause — sound plays when others join in">
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
</button>
<button @click="sendReaction('heart')" type="button"
x-show="!canPublish"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="React">
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
</button>
<button @click="toggleFeatures()" type="button"
class="rounded-full p-3 transition-colors sm:hidden"
:class="featuresOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
@@ -295,13 +316,13 @@
</button>
</div>
<button @click="raiseHand()" type="button" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Raise hand">
<button @click="raiseHand()" type="button" x-show="canPublish" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Raise hand">
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
</button>
<button @click="sendReaction('thumbs')" type="button" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Applause">
<button @click="sendReaction('thumbs')" type="button" x-show="canPublish" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Applause — sound plays when others join in">
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
</button>
<button @click="sendReaction('heart')" type="button" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
<button @click="sendReaction('heart')" type="button" x-show="canPublish" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
</button>
<button @click="toggleChat()" type="button"
@@ -613,10 +634,10 @@
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div class="space-y-1">
<div class="space-y-1 sm:hidden">
<div class="space-y-1 sm:hidden" x-show="canPublish">
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
@click="raiseHand(); featuresOpen = false" />
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send a quick reaction to everyone"
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
@click="sendReaction('thumbs'); featuresOpen = false" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
@@ -624,6 +645,11 @@
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1 sm:hidden" x-show="!canPublish">
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<button @click="openUploadPicker()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">