Improve conference attendee controls and stage participant strip.
Deploy Ladill Meet / deploy (push) Successful in 58s
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:
Binary file not shown.
@@ -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%;
|
||||
|
||||
+149
-8
@@ -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,7 +698,24 @@ function meetRoom() {
|
||||
|
||||
return this.inCallParticipants()
|
||||
.filter((person) => person.uuid !== spotlightUuid)
|
||||
.sort((a, b) => {
|
||||
.sort((a, b) => this.compareStripParticipants(a, b));
|
||||
},
|
||||
|
||||
panelStripParticipants() {
|
||||
const onPanelRefs = new Set(this.getActivePanelSpeakerRefs());
|
||||
|
||||
return this.inCallParticipants()
|
||||
.filter((person) => {
|
||||
if (!this.isSpeakerPerson(person)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -695,7 +724,14 @@ function meetRoom() {
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user