Add speak access for webinar/conference attendees and fix Events linking UX.
Deploy Ladill Meet / deploy (push) Successful in 33s
Deploy Ladill Meet / deploy (push) Successful in 33s
Let attendees request host-granted microphone access with LiveKit reconnect, participant panel controls, and toolbar UI that only shows mic when allowed. Fix Events create URL, surface API key setup guidance, and align the Webinars sidebar icon with other nav items using currentColor strokes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+195
-4
@@ -53,6 +53,11 @@ function meetRoom() {
|
||||
goingLive: false,
|
||||
presenterRefs: [],
|
||||
audioOnly: false,
|
||||
audioOnlyPublish: false,
|
||||
usesSpeakAccess: false,
|
||||
canManageSpeak: false,
|
||||
speakGranted: false,
|
||||
speakRequested: false,
|
||||
isSpace: false,
|
||||
usesStageLayout: false,
|
||||
panelDiscussions: false,
|
||||
@@ -112,6 +117,11 @@ function meetRoom() {
|
||||
chatUrl: el.dataset.chatUrl,
|
||||
reactionUrl: el.dataset.reactionUrl,
|
||||
raiseHandUrl: el.dataset.raiseHandUrl,
|
||||
speakRequestUrl: el.dataset.speakRequestUrl,
|
||||
speakCancelUrl: el.dataset.speakCancelUrl,
|
||||
speakGrantUrl: el.dataset.speakGrantUrl,
|
||||
speakRevokeUrl: el.dataset.speakRevokeUrl,
|
||||
speakDismissUrl: el.dataset.speakDismissUrl,
|
||||
recordingStartUrl: el.dataset.recordingStartUrl,
|
||||
recordingStopUrl: el.dataset.recordingStopUrl,
|
||||
lockUrl: el.dataset.lockUrl,
|
||||
@@ -143,6 +153,11 @@ function meetRoom() {
|
||||
this.isHost = el.dataset.isHost === '1';
|
||||
this.config.participantUuid = el.dataset.participant || '';
|
||||
this.canPublish = el.dataset.canPublish !== '0';
|
||||
this.audioOnlyPublish = el.dataset.audioOnlyPublish === '1';
|
||||
this.usesSpeakAccess = el.dataset.usesSpeakAccess === '1';
|
||||
this.canManageSpeak = el.dataset.canManageSpeak === '1';
|
||||
this.speakGranted = el.dataset.speakGranted === '1';
|
||||
this.speakRequested = el.dataset.speakRequested === '1';
|
||||
this.inBreakout = el.dataset.inBreakout === '1';
|
||||
this.isWebinar = el.dataset.isWebinar === '1';
|
||||
this.isConference = el.dataset.isConference === '1';
|
||||
@@ -384,7 +399,7 @@ function meetRoom() {
|
||||
|
||||
async enableLocalMedia(activeRoom, attempts = 3) {
|
||||
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
|
||||
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
|
||||
const wantCamera = !this.videoOffOnJoin && !this.audioOnly && !this.audioOnlyPublish;
|
||||
const audioOptions = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
@@ -1583,6 +1598,9 @@ function meetRoom() {
|
||||
this.inBreakout = Boolean(media.in_breakout);
|
||||
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
|
||||
this.canPublish = Boolean(media.can_publish);
|
||||
this.audioOnlyPublish = Boolean(media.audio_only_publish);
|
||||
this.speakGranted = Boolean(media.speak_granted);
|
||||
this.speakRequested = Boolean(media.speak_requested);
|
||||
this.usesStageLayout = Boolean(media.uses_stage_layout);
|
||||
this.breakoutRoomName = media.breakout?.name || '';
|
||||
this.breakoutClosesAt = media.breakout?.closes_at || '';
|
||||
@@ -1596,6 +1614,8 @@ function meetRoom() {
|
||||
|
||||
const previousBreakoutId = this.assignedBreakoutRoomId;
|
||||
const previousCanPublish = this.canPublish;
|
||||
const previousAudioOnlyPublish = this.audioOnlyPublish;
|
||||
const previousSpeakGranted = this.speakGranted;
|
||||
const previousUsesStageLayout = this.usesStageLayout;
|
||||
|
||||
this.applyMediaSessionState(media);
|
||||
@@ -1604,6 +1624,8 @@ function meetRoom() {
|
||||
|
||||
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|
||||
|| previousCanPublish !== this.canPublish
|
||||
|| previousAudioOnlyPublish !== this.audioOnlyPublish
|
||||
|| previousSpeakGranted !== this.speakGranted
|
||||
|| previousUsesStageLayout !== this.usesStageLayout;
|
||||
|
||||
if (needsReconnect && this.config.configured) {
|
||||
@@ -1828,7 +1850,7 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
async toggleMute() {
|
||||
if (!room || !this.canPublish) return;
|
||||
if (!room || !this.showMicrophoneControl()) return;
|
||||
await this.ensureAudioPlayback();
|
||||
|
||||
const enabled = room.localParticipant.isMicrophoneEnabled;
|
||||
@@ -1855,7 +1877,7 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
async toggleVideo() {
|
||||
if (!room || this.audioOnly || !this.canPublish) return;
|
||||
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||
await this.ensureAudioPlayback();
|
||||
const enabled = room.localParticipant.isCameraEnabled;
|
||||
await room.localParticipant.setCameraEnabled(!enabled);
|
||||
@@ -1874,7 +1896,7 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
async toggleScreenShare() {
|
||||
if (!room || this.audioOnly || !this.canPublish) return;
|
||||
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||
this.isScreenSharing = !this.isScreenSharing;
|
||||
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||
if (!this.isScreenSharing) {
|
||||
@@ -1893,6 +1915,167 @@ function meetRoom() {
|
||||
});
|
||||
},
|
||||
|
||||
showMicrophoneControl() {
|
||||
if (!this.canPublish) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.audioOnlyPublish) {
|
||||
return this.speakGranted;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
showVideoControl() {
|
||||
return this.canPublish && !this.audioOnlyPublish;
|
||||
},
|
||||
|
||||
showAudienceControls() {
|
||||
if (!this.usesSpeakAccess) {
|
||||
return this.canPublish;
|
||||
}
|
||||
|
||||
return !this.canPublish || this.audioOnlyPublish;
|
||||
},
|
||||
|
||||
showStagePublisherControls() {
|
||||
return this.usesSpeakAccess && this.canPublish && !this.audioOnlyPublish;
|
||||
},
|
||||
|
||||
speakRequestParticipants() {
|
||||
return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted);
|
||||
},
|
||||
|
||||
syncSelfSpeakState() {
|
||||
const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid);
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.speakGranted = Boolean(self.speak_granted);
|
||||
this.speakRequested = Boolean(self.speak_requested);
|
||||
},
|
||||
|
||||
mergeSpeakParticipant(participant) {
|
||||
if (!participant?.uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid);
|
||||
if (index >= 0) {
|
||||
this.sessionParticipants[index] = {
|
||||
...this.sessionParticipants[index],
|
||||
speak_requested: Boolean(participant.speak_requested),
|
||||
speak_granted: Boolean(participant.speak_granted),
|
||||
};
|
||||
}
|
||||
|
||||
if (participant.uuid === this.config.participantUuid) {
|
||||
this.speakGranted = Boolean(participant.speak_granted);
|
||||
this.speakRequested = Boolean(participant.speak_requested);
|
||||
}
|
||||
},
|
||||
|
||||
speakActionUrl(template, participantUuid) {
|
||||
return template?.replace('__UUID__', participantUuid) || '';
|
||||
},
|
||||
|
||||
async requestSpeak() {
|
||||
if (!this.config.speakRequestUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(this.config.speakRequestUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mergeSpeakParticipant(data.participant);
|
||||
},
|
||||
|
||||
async cancelSpeakRequest() {
|
||||
if (!this.config.speakCancelUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(this.config.speakCancelUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mergeSpeakParticipant(data.participant);
|
||||
},
|
||||
|
||||
async grantSpeakAccess(participantUuid) {
|
||||
const url = this.speakActionUrl(this.config.speakGrantUrl, participantUuid);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mergeSpeakParticipant(data.participant);
|
||||
await this.poll();
|
||||
},
|
||||
|
||||
async revokeSpeakAccess(participantUuid) {
|
||||
const url = this.speakActionUrl(this.config.speakRevokeUrl, participantUuid);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mergeSpeakParticipant(data.participant);
|
||||
await this.poll();
|
||||
},
|
||||
|
||||
async dismissSpeakRequest(participantUuid) {
|
||||
const url = this.speakActionUrl(this.config.speakDismissUrl, participantUuid);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mergeSpeakParticipant(data.participant);
|
||||
},
|
||||
|
||||
async goLive() {
|
||||
if (!this.config.goLiveUrl || this.goingLive) {
|
||||
return;
|
||||
@@ -2059,10 +2242,18 @@ function meetRoom() {
|
||||
return;
|
||||
}
|
||||
if (data.participants) {
|
||||
const previousSpeakRequestCount = this.speakRequestParticipants().length;
|
||||
this.sessionParticipants = data.participants;
|
||||
if (this.usesSpeakAccess) {
|
||||
this.syncSelfSpeakState();
|
||||
}
|
||||
this.pruneTilesFromSession();
|
||||
this.refreshParticipantTileAvatars();
|
||||
this.renderAudienceStrip();
|
||||
const speakRequestCount = this.speakRequestParticipants().length;
|
||||
if (this.canManageSpeak && speakRequestCount > previousSpeakRequestCount) {
|
||||
this.openParticipantsPanel();
|
||||
}
|
||||
}
|
||||
if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') {
|
||||
this.mergeStageConfig(data);
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{{ session('success') }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@php use App\Support\EventsSourceLink; @endphp
|
||||
<x-app-layout :title="'Link to Ladill Events · '.$room->title">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<a href="{{ $room->isWebinar() ? route('meet.webinars.show', $room) : route('meet.conferences.show', $room) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">← Back</a>
|
||||
@@ -15,7 +16,7 @@
|
||||
@if ($events === [])
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
<p>No virtual or hybrid events found in your Ladill Events account.</p>
|
||||
<a href="{{ $eventsAppUrl }}/qr-codes/create" target="_blank" rel="noopener" class="mt-4 inline-flex btn-primary btn-primary-sm">Create event in Events</a>
|
||||
<a href="{{ EventsSourceLink::createEventUrl($room) }}" target="_blank" rel="noopener" class="mt-4 inline-flex btn-primary btn-primary-sm">Create event in Events</a>
|
||||
</div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('meet.events.link.store', $room) }}" class="mt-6 space-y-5">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
$eventsUrls = EventsSourceLink::urls($room);
|
||||
$isLinked = EventsSourceLink::isLinked($room);
|
||||
$eventTitle = EventsSourceLink::eventTitle($room);
|
||||
$eventsBase = EventsSourceLink::baseUrl();
|
||||
$eventsConfigured = EventsSourceLink::isApiConfigured();
|
||||
$createEventUrl = EventsSourceLink::createEventUrl($room);
|
||||
$kind = $room->isConference() ? 'conference' : 'webinar';
|
||||
@endphp
|
||||
|
||||
@@ -32,6 +33,17 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! $isLinked && ! $eventsConfigured)
|
||||
<div class="mt-4 rounded-lg border border-amber-300/80 bg-amber-100/60 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">Events linking is not configured on this Meet instance.</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-amber-900/90">
|
||||
Set <span class="font-mono">MEET_API_KEY_EVENTS</span> in Meet and the matching
|
||||
<span class="font-mono">EVENTS_API_KEY_MEET</span> in Events so Meet can list and link your events.
|
||||
You can still create an event in Events using the button below.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($isLinked)
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
@if ($eventsUrls['registration'])
|
||||
@@ -72,8 +84,12 @@
|
||||
<p class="mt-3 text-xs text-slate-500">Programme speakers and panelists are configured on the event programme in Events and sync to this session when linked.</p>
|
||||
@else
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
|
||||
<a href="{{ $eventsBase }}/qr-codes/create" target="_blank" rel="noopener" class="btn-secondary btn-secondary-sm">Create event in Events</a>
|
||||
@if ($eventsConfigured)
|
||||
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
|
||||
@else
|
||||
<span class="btn-primary btn-primary-sm cursor-not-allowed opacity-50" title="Configure MEET_API_KEY_EVENTS to enable linking">Link to Ladill Events</span>
|
||||
@endif
|
||||
<a href="{{ $createEventUrl }}" target="_blank" rel="noopener" class="btn-secondary btn-secondary-sm">Create event in Events</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
'start-breakout',
|
||||
'upload-file',
|
||||
'programme',
|
||||
'speak',
|
||||
];
|
||||
$iconName = in_array($icon, $allowed, true) ? $icon : null;
|
||||
$iconPath = $iconName ? public_path('images/meet-icons/'.$iconName.'.svg') : null;
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
data-chat-url="{{ route('meet.room.chat', $session) }}"
|
||||
data-reaction-url="{{ route('meet.room.reaction', $session) }}"
|
||||
data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}"
|
||||
data-speak-request-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.request', $session) : '' }}"
|
||||
data-speak-cancel-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.cancel', $session) : '' }}"
|
||||
data-speak-grant-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.grant', [$session, '__UUID__']) : '' }}"
|
||||
data-speak-revoke-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.revoke', [$session, '__UUID__']) : '' }}"
|
||||
data-speak-dismiss-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.dismiss', [$session, '__UUID__']) : '' }}"
|
||||
data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}"
|
||||
data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}"
|
||||
data-leave-url="{{ route('meet.room.leave', $session) }}"
|
||||
data-end-url="{{ route('meet.room.end', $session) }}"
|
||||
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
|
||||
@@ -99,6 +106,9 @@
|
||||
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
|
||||
data-presenter-refs='@json($presenterRefs ?? [])'
|
||||
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
||||
data-audio-only-publish="{{ ($audioOnlyPublish ?? false) ? '1' : '0' }}"
|
||||
data-speak-granted="{{ ($speakGranted ?? false) ? '1' : '0' }}"
|
||||
data-speak-requested="{{ ($speakRequested ?? false) ? '1' : '0' }}"
|
||||
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
|
||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
||||
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
||||
@@ -318,11 +328,17 @@
|
||||
</main>
|
||||
|
||||
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4">
|
||||
<div x-show="speakGranted && audioOnlyPublish" x-cloak
|
||||
class="pointer-events-none absolute inset-x-0 -top-10 flex justify-center px-4 sm:-top-11">
|
||||
<p class="rounded-full bg-emerald-500/15 px-4 py-1.5 text-xs font-medium text-emerald-300 ring-1 ring-emerald-500/30">
|
||||
You can speak — your microphone is available
|
||||
</p>
|
||||
</div>
|
||||
<div class="meet-toolbar mx-auto max-w-5xl">
|
||||
<div class="meet-toolbar__track sm:px-0">
|
||||
<div class="meet-toolbar__primary sm:contents">
|
||||
<button @click="toggleMute()" type="button"
|
||||
x-show="canPublish"
|
||||
x-show="showMicrophoneControl()"
|
||||
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'">
|
||||
@@ -330,7 +346,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="canPublish && !audioOnly"
|
||||
x-show="showVideoControl()"
|
||||
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'">
|
||||
@@ -338,26 +354,26 @@
|
||||
<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="canPublish && !audioOnly"
|
||||
x-show="showVideoControl()"
|
||||
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"
|
||||
x-show="showAudienceControls()"
|
||||
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"
|
||||
x-show="showAudienceControls()"
|
||||
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"
|
||||
x-show="showAudienceControls()"
|
||||
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||
title="React">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||
@@ -370,13 +386,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<button @click="raiseHand()" type="button" x-show="showAudienceControls()" 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" 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">
|
||||
<button @click="sendReaction('thumbs')" type="button" x-show="showAudienceControls()" 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" x-show="canPublish" 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="showAudienceControls()" 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"
|
||||
@@ -564,6 +580,31 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="canManageSpeak && speakRequestParticipants().length">
|
||||
<div class="mb-3 border-b border-slate-800 pb-3">
|
||||
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-sky-300">
|
||||
Requesting to speak
|
||||
<span class="ml-1 rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] text-sky-200"
|
||||
x-text="speakRequestParticipants().length"></span>
|
||||
</p>
|
||||
<template x-for="person in speakRequestParticipants()" :key="'speak-req-'+person.uuid">
|
||||
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/20 text-sm font-semibold text-sky-200"
|
||||
x-text="participantInitials(person.display_name)"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-white" x-text="person.display_name"></span>
|
||||
<span class="block text-xs text-sky-200/80">Wants to speak</span>
|
||||
</span>
|
||||
<span class="flex shrink-0 gap-1">
|
||||
<button type="button" @click="grantSpeakAccess(person.uuid)"
|
||||
class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-500">Allow</button>
|
||||
<button type="button" @click="dismissSpeakRequest(person.uuid)"
|
||||
class="rounded-lg bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-200 hover:bg-slate-600">Dismiss</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="person in (usesStageLayout ? speakerParticipantsSorted() : inCallParticipantsSorted())" :key="person.uuid">
|
||||
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5"
|
||||
:class="isSpeaking(person.uuid) ? 'bg-emerald-500/10 ring-1 ring-emerald-500/40' : ''">
|
||||
@@ -595,8 +636,22 @@
|
||||
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
|
||||
Make speaker
|
||||
</button>
|
||||
<button x-show="canManageSpeak && usesSpeakAccess && person.role === 'attendee' && !person.speak_granted"
|
||||
type="button" @click="grantSpeakAccess(person.uuid)"
|
||||
class="rounded-lg bg-emerald-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-emerald-500">
|
||||
Invite to speak
|
||||
</button>
|
||||
<button x-show="canManageSpeak && usesSpeakAccess && person.speak_granted"
|
||||
type="button" @click="revokeSpeakAccess(person.uuid)"
|
||||
class="rounded-lg bg-slate-600 px-2 py-1 text-[10px] font-medium text-slate-100 hover:bg-slate-500">
|
||||
Revoke speak
|
||||
</button>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1 text-slate-400">
|
||||
<span x-show="person.speak_granted" class="rounded-full bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300" title="Can speak">Mic</span>
|
||||
<span x-show="person.speak_requested && !person.speak_granted" class="rounded-full bg-sky-500/20 p-1 text-sky-300" title="Requested to speak">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-3.5 w-3.5'])
|
||||
</span>
|
||||
<span x-show="isSpeaking(person.uuid)" class="meet-speaking-badge rounded-full bg-emerald-500/20 p-1 text-emerald-300" title="Speaking">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
|
||||
@@ -709,7 +764,7 @@
|
||||
|
||||
<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" x-show="canPublish">
|
||||
<div class="space-y-1 sm:hidden" x-show="showAudienceControls()">
|
||||
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
|
||||
@click="raiseHand(); closeSidebar()" />
|
||||
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
|
||||
@@ -720,7 +775,40 @@
|
||||
@click="toggleChat()" />
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
<div class="space-y-1 sm:hidden" x-show="!canPublish">
|
||||
<div class="space-y-1" x-show="usesSpeakAccess && !speakGranted && !speakRequested && showAudienceControls()">
|
||||
<x-meet.room.menu-item icon="speak" title="Request to speak" subtitle="Ask the host to enable your microphone"
|
||||
@click="requestSpeak(); closeSidebar()" />
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
<div class="space-y-1" x-show="usesSpeakAccess && speakRequested && !speakGranted">
|
||||
<button type="button" disabled
|
||||
class="flex w-full cursor-default items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-400">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/15">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-5 w-5'])
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium text-sky-300">Speak request sent</span>
|
||||
<span class="block text-xs text-slate-500">Waiting for the host to allow your microphone</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" @click="cancelSpeakRequest(); closeSidebar()"
|
||||
class="ml-12 text-xs font-medium text-slate-400 hover:text-slate-200">Cancel request</button>
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
<div class="space-y-1" x-show="usesSpeakAccess && speakGranted && audioOnlyPublish">
|
||||
<button type="button" disabled
|
||||
class="flex w-full cursor-default items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-400">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-emerald-500/15">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-5 w-5'])
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium text-emerald-300">You can speak</span>
|
||||
<span class="block text-xs text-slate-500">Your microphone is available — use the mic control below</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
<div class="space-y-1 sm:hidden" x-show="showStagePublisherControls()">
|
||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||
@click="toggleChat()" />
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{{ session('success') }}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
['name' => 'Conferences', 'route' => route('meet.conferences.index'), 'active' => request()->routeIs('meet.conferences.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'],
|
||||
['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'),
|
||||
'iconImg' => 'webinar'],
|
||||
'iconViewBox' => '0 0 14 14',
|
||||
'icon' => '<path d="M7 12.52C10.3137 12.52 13 9.83373 13 6.52002C13 3.20631 10.3137 0.52002 7 0.52002C3.68629 0.52002 1 3.20631 1 6.52002C1 9.83373 3.68629 12.52 7 12.52Z" /><path d="M10.07 11.6799C11.0132 12.1003 11.858 12.7135 12.55 13.4799" /><path d="M1.45 13.4799C2.14202 12.7135 2.9868 12.1003 3.93 11.6799" /><path d="M7 9.52002C8.65685 9.52002 10 8.17687 10 6.52002C10 4.86317 8.65685 3.52002 7 3.52002C5.34315 3.52002 4 4.86317 4 6.52002C4 8.17687 5.34315 9.52002 7 9.52002Z" /><path d="M7 7.02002C7.27614 7.02002 7.5 6.79616 7.5 6.52002C7.5 6.24388 7.27614 6.02002 7 6.02002C6.72386 6.02002 6.5 6.24388 6.5 6.52002C6.5 6.79616 6.72386 7.02002 7 7.02002Z" />'],
|
||||
['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />'],
|
||||
['name' => 'Reports', 'route' => route('meet.reports.index'), 'active' => request()->routeIs('meet.reports.*'),
|
||||
@@ -53,12 +54,7 @@
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach ($nav as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
@if (! empty($item['iconImg']))
|
||||
@php $navIconVer = @filemtime(public_path('images/meet-icons/'.$item['iconImg'].'.svg')) ?: '1'; @endphp
|
||||
<img src="{{ asset('images/meet-icons/'.$item['iconImg'].'.svg') }}?v={{ $navIconVer }}" alt="" class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'opacity-100' : 'opacity-45 group-hover:opacity-70' }}">
|
||||
@else
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $item['icon'] !!}</svg>
|
||||
@endif
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
Reference in New Issue
Block a user