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>
898 lines
66 KiB
PHP
898 lines
66 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en" class="h-full meet-room-page">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>{{ $room->title }} · Ladill Meet</title>
|
|
@include('partials.favicon')
|
|
<style>[x-cloak]{display:none!important}</style>
|
|
@vite(['resources/css/app.css', 'resources/js/meet-room.js'])
|
|
</head>
|
|
<body class="h-full overflow-hidden bg-slate-950 font-sans text-white" x-data="meetRoom()" x-init="init()">
|
|
@php
|
|
use App\Models\User;
|
|
use App\Services\Meet\ParticipantPresenter;
|
|
|
|
$joined = $session->participants->whereIn('status', ['joined', 'waiting']);
|
|
$participantAvatars = ParticipantPresenter::avatarMap($joined, $room->host_user_ref);
|
|
$joinedParticipants = $joined
|
|
->map(fn ($p) => ParticipantPresenter::toArray($p, $participantAvatars, $room->host_user_ref))
|
|
->values();
|
|
$roomHostUser = User::query()->where('public_id', $room->host_user_ref)->first();
|
|
$roomHost = [
|
|
'user_ref' => $room->host_user_ref,
|
|
'display_name' => $roomHostUser?->name ?? 'Host',
|
|
'avatar_url' => $roomHostUser?->avatarUrl(),
|
|
];
|
|
$isConferenceSession = $isConference ?? false;
|
|
$sessionNoun = match (true) {
|
|
$isConferenceSession => 'conference',
|
|
$room->isWebinar() => 'webinar',
|
|
default => 'meeting',
|
|
};
|
|
$sessionNounTitle = ucfirst($sessionNoun);
|
|
$presenterRefsList = (array) ($presenterRefs ?? []);
|
|
$headerHostPerson = ParticipantPresenter::headerHost($joinedParticipants->all(), $roomHost);
|
|
$headerParticipantLabel = ParticipantPresenter::headerCountLabel(
|
|
$joinedParticipants->all(),
|
|
(bool) ($usesStageLayout ?? false),
|
|
$presenterRefsList,
|
|
);
|
|
$headerWaitingCount = $joinedParticipants->where('status', 'waiting')->count();
|
|
$stageLayoutName = ($stageConfig['stage_layout'] ?? 'plenary') === 'panel' ? 'Panel discussion' : 'Plenary';
|
|
$initialConferenceStatus = match (true) {
|
|
($inBreakout ?? false) => 'Breakout · meeting mode',
|
|
($isGreenRoom ?? false) && ($usesStageLayout ?? false) => "Green room · {$stageLayoutName}",
|
|
($isGreenRoom ?? false) => 'Green room',
|
|
! ($usesStageLayout ?? false) => 'Conference live',
|
|
($stageConfig['stage_layout'] ?? 'plenary') === 'panel' => 'Panel discussion live',
|
|
default => 'Plenary live',
|
|
};
|
|
$initialConferenceStatusClass = match (true) {
|
|
($inBreakout ?? false) => 'text-amber-400/90',
|
|
($isGreenRoom ?? false) => 'text-violet-400/90',
|
|
($stageConfig['stage_layout'] ?? 'plenary') === 'panel' => 'text-violet-400/90',
|
|
default => 'text-sky-400/90',
|
|
};
|
|
@endphp
|
|
<div id="meet-config"
|
|
data-token="{{ $mediaToken }}"
|
|
data-url="{{ $mediaUrl }}"
|
|
data-configured="{{ $mediaConfigured ? '1' : '0' }}"
|
|
data-participant="{{ $participant->uuid }}"
|
|
data-display-name="{{ $participant->display_name }}"
|
|
data-is-host="{{ ($canManageConference ?? $participant->isHost()) ? '1' : '0' }}"
|
|
data-poll-url="{{ route('meet.room.poll', $session) }}"
|
|
data-ended-url="{{ route('meet.ended', $session) }}"
|
|
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) }}"
|
|
data-recording-stop-url="{{ route('meet.room.recording.stop', $session) }}"
|
|
data-lock-url="{{ route('meet.room.lock', $session) }}"
|
|
data-unlock-url="{{ route('meet.room.unlock', $session) }}"
|
|
data-caption-url="{{ route('meet.room.caption', $session) }}"
|
|
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
|
|
data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '1' : '0' }}"
|
|
data-video-off-on-join="{{ ($isAudioOnly ?? false) || $room->setting('video_off_on_join', false) ? '1' : '0' }}"
|
|
data-audio-only="{{ ($isAudioOnly ?? false) ? '1' : '0' }}"
|
|
data-is-space="{{ ($isSpace ?? false) ? '1' : '0' }}"
|
|
data-watermark="{{ $watermark ? '1' : '0' }}"
|
|
data-locked="{{ $session->is_locked ? '1' : '0' }}"
|
|
data-recording-active="{{ $activeRecording ? '1' : '0' }}"
|
|
data-is-webinar="{{ ($isWebinar ?? false) ? '1' : '0' }}"
|
|
data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}"
|
|
data-session-noun="{{ $sessionNoun }}"
|
|
data-is-green-room="{{ ($isGreenRoom ?? false) ? '1' : '0' }}"
|
|
data-session-mode="{{ $session->session_mode }}"
|
|
data-go-live-url="{{ ($isConference ?? false) && ($isGreenRoom ?? false) && ($canManageConference ?? $participant->isHost()) ? route('meet.room.townhall.live', $session) : '' }}"
|
|
data-add-presenter-url="{{ ($isConference ?? false) && ($canManageConference ?? $participant->isHost()) ? route('meet.room.townhall.presenters', $session) : '' }}"
|
|
data-stage-update-url="{{ ($usesStageLayout ?? false) && ($canManageConference ?? $participant->isHost()) ? route('meet.room.stage.update', $session) : '' }}"
|
|
data-uses-stage-layout="{{ ($usesStageLayout ?? false) ? '1' : '0' }}"
|
|
data-panel-discussions="{{ ($stageConfig['panel_discussions'] ?? false) ? '1' : '0' }}"
|
|
data-stage-layout="{{ $stageConfig['stage_layout'] ?? 'plenary' }}"
|
|
data-panels='@json($stageConfig['panels'] ?? [])'
|
|
data-active-panel-id="{{ $stageConfig['active_panel_id'] ?? '' }}"
|
|
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) }}"
|
|
data-breakouts-create-url="{{ route('meet.room.breakouts.create', $session) }}"
|
|
data-breakouts-close-url="{{ route('meet.room.breakouts.close', $session) }}"
|
|
data-breakouts-return-url="{{ route('meet.room.breakouts.return', $session) }}"
|
|
data-breakouts-broadcast-url="{{ route('meet.room.breakouts.broadcast', $session) }}"
|
|
data-media-token-url="{{ route('meet.room.media-token', $session) }}"
|
|
data-files-upload-url="{{ route('meet.room.files.upload', $session) }}"
|
|
data-whiteboard-url="{{ route('meet.room.whiteboard.show', $session) }}"
|
|
data-whiteboard-save-url="{{ route('meet.room.whiteboard.save', $session) }}"
|
|
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)'
|
|
data-room-host='@json($roomHost)'
|
|
hidden></div>
|
|
|
|
@if (! $mediaConfigured)
|
|
<div class="absolute inset-x-0 top-0 z-50 bg-amber-500 px-4 py-2 text-center text-sm font-medium text-amber-950">
|
|
LiveKit is not configured. Set LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET in .env for video.
|
|
</div>
|
|
@endif
|
|
|
|
<div class="flex h-full min-w-0">
|
|
<div class="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
|
<header class="flex shrink-0 items-center justify-between bg-slate-950 px-4 py-3">
|
|
<div class="min-w-0">
|
|
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
|
|
@if ($isAudioOnly ?? false)
|
|
<p class="text-[10px] font-medium uppercase tracking-wide text-emerald-400/90">Audio room</p>
|
|
@elseif ($isConference ?? false)
|
|
<p class="text-[10px] font-medium uppercase tracking-wide {{ $initialConferenceStatusClass }}"
|
|
:class="conferenceStatusClass()"
|
|
x-text="conferenceStatusLabel()">{{ $initialConferenceStatus }}</p>
|
|
@endif
|
|
<p class="min-h-4 truncate text-xs font-medium leading-4"
|
|
:class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'"
|
|
:title="activeSpeakersMessage()"
|
|
x-text="activeSpeakersMessage() || '...'"></p>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
|
|
<button @click.stop="openShare()" data-share-trigger type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
|
title="Share {{ $sessionNoun }} link">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'share', 'class' => 'h-4 w-4'])
|
|
Share
|
|
</button>
|
|
<span x-show="isRecording" @unless($activeRecording) style="display: none" @endunless class="rounded bg-red-600 px-2 py-0.5 font-medium text-white">REC</span>
|
|
<span x-show="isLocked" @unless($session->is_locked) style="display: none" @endunless class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white">Locked</span>
|
|
<button @click="toggleParticipants()" type="button"
|
|
class="relative inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
|
:class="isHost && waitingCount > 0 ? 'ring-2 ring-amber-400/60' : ''"
|
|
:title="usesStageLayout ? 'View speakers' : 'View participants'">
|
|
<span x-show="isHost && waitingCount > 0" x-cloak
|
|
x-text="waitingCount > 9 ? '9+' : waitingCount"
|
|
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-amber-950"></span>
|
|
@if ($headerHostPerson)
|
|
<span class="relative flex h-7 w-7 shrink-0 overflow-hidden rounded-full" aria-hidden="true">
|
|
@if (! empty($headerHostPerson['avatar_url']))
|
|
<img src="{{ $headerHostPerson['avatar_url'] }}" alt="" width="28" height="28"
|
|
class="h-7 w-7 rounded-full object-cover">
|
|
@else
|
|
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-slate-700 text-[11px] font-semibold text-white">
|
|
{{ ParticipantPresenter::initials($headerHostPerson['display_name']) }}
|
|
</span>
|
|
@endif
|
|
</span>
|
|
@endif
|
|
<span x-text="headerParticipantLabel()">{{ $headerParticipantLabel }}</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div x-show="isConference && isGreenRoom" x-cloak
|
|
class="flex shrink-0 flex-col gap-2 border-b border-violet-500/30 bg-violet-500/15 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
<div class="min-w-0 text-sm text-violet-100">
|
|
<p class="font-medium">Green room</p>
|
|
<p class="truncate text-xs text-violet-200/90">Only hosts and speakers are visible. Attendees join after you go live.</p>
|
|
<p x-show="usesStageLayout" class="mt-1 text-xs text-violet-200/90">
|
|
Stage layout:
|
|
<span class="font-semibold text-violet-50" x-text="stageLayoutLabel()"></span>
|
|
</p>
|
|
</div>
|
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
<button x-show="isHost && usesStageLayout" @click="openPanelSetup()" type="button"
|
|
class="shrink-0 rounded-lg border border-violet-400/40 bg-violet-500/20 px-3 py-1.5 text-xs font-semibold text-violet-100 transition-colors hover:bg-violet-500/30">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'panel', 'class' => 'mr-1 inline h-3.5 w-3.5'])
|
|
Stage layout
|
|
</button>
|
|
<button x-show="isHost" @click="goLive()" type="button" :disabled="goingLive"
|
|
class="shrink-0 rounded-lg bg-violet-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-violet-400 disabled:opacity-60">
|
|
<span x-text="goingLive ? 'Starting…' : 'Go live'">Go live</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="inBreakout" x-cloak
|
|
class="flex shrink-0 flex-col gap-1 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-semibold text-amber-100">Breakout session</p>
|
|
<p class="truncate text-xs text-amber-200/90" x-text="breakoutRoomName ? 'You are in ' + breakoutRoomName : 'You are in a breakout room'"></p>
|
|
</div>
|
|
<p x-show="breakoutTimeRemaining()" class="shrink-0 text-xs font-medium text-amber-200/90" x-text="breakoutTimeRemaining()"></p>
|
|
</div>
|
|
|
|
<div x-show="isHost && breakoutsActive && !inBreakout" x-cloak
|
|
class="flex shrink-0 flex-col gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="min-w-0 text-sm text-amber-100">
|
|
<p class="font-medium">Breakouts are active</p>
|
|
<p class="text-xs text-amber-200/90">Attendees are in smaller discussion groups. You remain on the main stage.</p>
|
|
</div>
|
|
<button @click="closeBreakouts()" type="button"
|
|
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 hover:bg-amber-400">
|
|
End breakouts
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="usesStageLayout && !isGreenRoom && !inBreakout" x-cloak
|
|
class="flex shrink-0 flex-col gap-2 border-b px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
|
|
:class="stageLayout === 'panel' ? 'border-violet-500/25 bg-violet-500/10' : 'border-sky-500/25 bg-sky-500/10'">
|
|
<div class="flex min-w-0 items-start gap-2.5">
|
|
<span class="mt-1.5 flex h-2.5 w-2.5 shrink-0 rounded-full"
|
|
:class="stageLayout === 'panel' ? 'bg-violet-400' : 'bg-sky-400'"></span>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-semibold"
|
|
:class="stageLayout === 'panel' ? 'text-violet-100' : 'text-sky-100'"
|
|
x-text="stageLayoutLabel()"></p>
|
|
<p x-show="stageLayoutDescription()" class="truncate text-xs"
|
|
:class="stageLayout === 'panel' ? 'text-violet-200/85' : 'text-sky-200/85'"
|
|
x-text="stageLayoutDescription()"></p>
|
|
</div>
|
|
</div>
|
|
<button x-show="isHost" @click="openPanelSetup()" type="button"
|
|
class="inline-flex shrink-0 items-center gap-1.5 self-start rounded-lg border px-3 py-1.5 text-xs font-semibold transition-colors sm:self-center"
|
|
:class="stageLayout === 'panel'
|
|
? 'border-violet-400/40 bg-violet-500/20 text-violet-100 hover:bg-violet-500/30'
|
|
: 'border-sky-400/40 bg-sky-500/20 text-sky-100 hover:bg-sky-500/30'">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'panel', 'class' => 'h-3.5 w-3.5'])
|
|
Change layout
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="isHost && waitingCount > 0" x-cloak
|
|
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
|
|
<div class="flex min-w-0 items-center gap-2 text-sm text-amber-100">
|
|
<span class="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-amber-400"></span>
|
|
<span class="truncate"
|
|
x-text="waitingCount === 1 ? '1 person is waiting to be admitted' : waitingCount + ' people are waiting to be admitted'"></span>
|
|
</div>
|
|
<button @click="openParticipantsPanel()" type="button"
|
|
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 transition-colors hover:bg-amber-400">
|
|
Review
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="audioPlaybackBlocked || needsMediaUnlock" x-cloak
|
|
class="flex shrink-0 items-center justify-between gap-3 border-b border-indigo-500/30 bg-indigo-500/15 px-4 py-2.5">
|
|
<p class="text-sm text-indigo-100"
|
|
x-text="needsMediaUnlock ? 'Microphone blocked — click to allow speaking and hearing others.' : 'Audio playback blocked — click to hear other participants.'"></p>
|
|
<button @click="unlockAllAudio()" type="button"
|
|
class="shrink-0 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-indigo-400">
|
|
Enable audio
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="shareOpen" x-cloak
|
|
@click.outside="if (! $event.target.closest('[data-share-trigger]')) shareOpen = false"
|
|
class="absolute right-4 top-14 z-50 w-80 rounded-xl bg-slate-900 p-4 shadow-2xl">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p class="text-sm font-medium text-white">Invite others</p>
|
|
<p class="mt-0.5 text-xs text-slate-400">Share this link so people can join the {{ $sessionNoun }}.</p>
|
|
</div>
|
|
<button @click="shareOpen = false" type="button" class="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-white" title="Close">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="mt-3 flex gap-2">
|
|
<input type="text" readonly :value="joinUrl"
|
|
class="min-w-0 flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-xs text-slate-200 ring-1 ring-slate-700/60">
|
|
<button @click="copyJoinLink()" type="button"
|
|
class="shrink-0 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500"
|
|
x-text="shareCopied ? 'Copied' : 'Copy'"></button>
|
|
</div>
|
|
<p x-show="passcode" class="mt-3 text-xs text-slate-400">
|
|
Passcode: <span class="font-mono font-medium text-slate-200" x-text="passcode"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<main class="relative flex min-h-0 min-w-0 flex-1 flex-col p-4 pb-2">
|
|
<div id="meet-stage" class="flex h-full min-h-0 flex-1 flex-col gap-3" :class="usesStageLayout ? 'meet-stage meet-stage--' + stageLayout : ''">
|
|
<div id="meet-stage-spotlight" class="meet-stage-spotlight relative min-h-0 flex-1 overflow-hidden rounded-xl bg-slate-900"></div>
|
|
<div id="video-grid" class="grid min-h-0 gap-2 sm:grid-cols-2 lg:grid-cols-3" :class="audioOnly ? 'meet-audio-grid' : (usesStageLayout ? 'meet-stage-panel-grid flex-1' : 'h-full')"></div>
|
|
<div id="meet-audience-strip" class="meet-audience-strip shrink-0"></div>
|
|
</div>
|
|
<div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div>
|
|
|
|
<div x-show="watermark" class="pointer-events-none absolute right-6 top-6 rounded bg-black/40 px-3 py-1 text-xs text-white/80" x-text="displayName"></div>
|
|
|
|
<div x-show="liveCaptions && captionText" class="pointer-events-none absolute inset-x-0 bottom-20 flex justify-center px-4">
|
|
<p class="max-w-2xl rounded-lg bg-black/70 px-4 py-2 text-center text-sm text-white" x-text="captionText"></p>
|
|
</div>
|
|
|
|
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center gap-2">
|
|
<template x-for="reaction in floatingReactions" :key="reaction.id">
|
|
<span class="flex h-10 w-10 animate-bounce items-center justify-center rounded-full bg-slate-800/90">
|
|
<img x-show="reaction.kind === 'thumbs'" src="{{ asset('images/meet-icons/applause.svg') }}?v={{ @filemtime(public_path('images/meet-icons/applause.svg')) ?: '1' }}" alt="" class="meet-toolbar-icon h-5 w-5">
|
|
<img x-show="reaction.kind === 'heart'" src="{{ asset('images/meet-icons/react.svg') }}?v={{ @filemtime(public_path('images/meet-icons/react.svg')) ?: '1' }}" alt="" class="meet-toolbar-icon h-5 w-5">
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</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="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'">
|
|
<svg x-show="!isMuted" 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"/></svg>
|
|
<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="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'">
|
|
<svg x-show="!isVideoOff" 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"/></svg>
|
|
<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="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="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="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="showAudienceControls()"
|
|
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="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
|
title="More options">
|
|
<svg 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="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<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="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="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"
|
|
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
|
|
:class="sidebarPanel === 'chat' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
|
title="Chat">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'chat'])
|
|
</button>
|
|
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
|
|
<button @click="toggleFeatures()" type="button"
|
|
class="hidden items-center gap-1.5 rounded-full px-3.5 py-3 text-xs font-medium transition-colors sm:inline-flex"
|
|
:class="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
|
title="More options">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
|
|
More
|
|
</button>
|
|
|
|
<div class="meet-toolbar__divider sm:hidden" aria-hidden="true"></div>
|
|
|
|
<div class="meet-toolbar__actions sm:contents">
|
|
<form method="POST" action="{{ route('meet.room.leave', $session) }}" class="inline">
|
|
@csrf
|
|
<button type="submit"
|
|
class="rounded-full bg-red-600 p-3 font-medium hover:bg-red-500 sm:px-5 sm:py-3 sm:text-sm"
|
|
title="Leave {{ $sessionNoun }}"
|
|
aria-label="Leave {{ $sessionNoun }}">
|
|
<span class="sm:hidden">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'leave'])
|
|
</span>
|
|
<span class="hidden sm:inline">Leave</span>
|
|
</button>
|
|
</form>
|
|
@if ($participant->isHost())
|
|
<button @click="openEndMeetingConfirm()" type="button"
|
|
class="rounded-full bg-red-950 p-3 text-red-400 hover:bg-red-900 sm:px-4 sm:py-3 sm:text-sm"
|
|
title="End {{ $sessionNoun }} for everyone"
|
|
aria-label="End {{ $sessionNoun }} for everyone">
|
|
<span class="sm:hidden">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'end'])
|
|
</span>
|
|
<span class="hidden sm:inline">End</span>
|
|
</button>
|
|
<form id="meet-end-form" method="POST" action="{{ route('meet.room.end', $session) }}" class="hidden">
|
|
@csrf
|
|
</form>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
<div x-show="breakoutModalOpen" x-cloak
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
|
@keydown.escape.window="breakoutModalOpen = false">
|
|
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="breakoutModalOpen = false">
|
|
<h3 class="text-base font-semibold text-white">Start breakout rooms</h3>
|
|
<p class="mt-1 text-sm text-slate-400">Attendees and speakers are split into smaller groups with full meeting controls. You stay on the main stage.</p>
|
|
<form @submit.prevent="submitBreakouts()" class="mt-4 space-y-4">
|
|
<div>
|
|
<label for="breakout-count" class="sr-only">Number of rooms</label>
|
|
<input id="breakout-count" type="number" min="1" max="20" x-model="breakoutCount"
|
|
class="w-full rounded-xl border-0 bg-slate-950 px-3 py-2.5 text-sm text-white ring-1 ring-slate-700/60 focus:ring-indigo-500">
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<button type="button" @click="breakoutModalOpen = false"
|
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
|
|
<button type="submit"
|
|
class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Create rooms</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="endMeetingConfirmOpen" x-cloak
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
|
@keydown.escape.window="endMeetingConfirmOpen = false">
|
|
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="endMeetingConfirmOpen = false">
|
|
<h3 class="text-base font-semibold text-white">End {{ $sessionNoun }} for everyone?</h3>
|
|
<p class="mt-2 text-sm text-slate-400">All participants will be disconnected and the session will close.</p>
|
|
<div class="mt-5 flex justify-end gap-2">
|
|
<button type="button" @click="endMeetingConfirmOpen = false"
|
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
|
|
<button type="button" @click="submitEndMeeting()"
|
|
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End {{ $sessionNoun }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meet-room-panels-stack max-sm:contents"
|
|
:class="sidebarPanel ? 'is-open' : ''">
|
|
<div x-show="sidebarPanel" x-cloak
|
|
class="meet-room-panel__backdrop fixed inset-0 z-40 sm:hidden"
|
|
@click="closeSidebar()"
|
|
aria-hidden="true"></div>
|
|
|
|
<x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ $sessionNounTitle }} chat">
|
|
<x-slot:subtitle>
|
|
<p>Messages are visible to everyone in the call</p>
|
|
</x-slot:subtitle>
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col">
|
|
<div class="flex-1 space-y-3 overflow-y-auto" id="chat-messages">
|
|
@foreach ($messages as $message)
|
|
@php $isOwn = $message->sender_name === $participant->display_name; @endphp
|
|
<div class="meet-chat-bubble {{ $isOwn ? 'meet-chat-bubble--own' : '' }}">
|
|
<div class="rounded-2xl px-3 py-2 {{ $isOwn ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-100' }}">
|
|
@unless ($isOwn)
|
|
<p class="mb-0.5 text-xs font-medium text-sky-300">{{ $message->sender_name }}</p>
|
|
@endunless
|
|
<p class="text-sm leading-relaxed">{{ $message->body }}</p>
|
|
</div>
|
|
<p class="mt-1 text-[10px] text-slate-500 {{ $isOwn ? 'text-right' : '' }}">{{ $message->created_at->format('g:i A') }}</p>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
<form @submit.prevent="sendChat" class="mt-3 shrink-0">
|
|
<div class="flex items-end gap-2">
|
|
<input type="text" x-model="chatInput" placeholder="Write a message…"
|
|
class="flex-1 rounded-xl border-0 bg-slate-800 px-3 py-2.5 text-sm text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500">
|
|
<button type="submit"
|
|
class="rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-500 disabled:opacity-50"
|
|
:disabled="!chatInput.trim()">Send</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</x-meet.room.panel>
|
|
|
|
<x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme">
|
|
<x-slot:subtitle>
|
|
<p>Lineup from Events when linked to this {{ $sessionNoun }}</p>
|
|
</x-slot:subtitle>
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
@include('meet.room.partials.programme-panel', [
|
|
'programmeSnapshot' => $programmeSnapshot,
|
|
'linkedProgrammeItems' => $linkedProgrammeItems ?? [],
|
|
'sessionNoun' => $sessionNoun,
|
|
])
|
|
</div>
|
|
</x-meet.room.panel>
|
|
|
|
<x-meet.room.panel show="sidebarPanel === 'participants'" :title="null">
|
|
<x-slot:subtitle>
|
|
<template x-if="usesStageLayout">
|
|
<div>
|
|
<p x-text="speakerCount() + (speakerCount() === 1 ? ' speaker' : ' speakers') + ' on stage'"></p>
|
|
<p x-show="activeSpeakerCount() >= 1" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
|
|
</div>
|
|
</template>
|
|
<template x-if="!usesStageLayout">
|
|
<div>
|
|
<p x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
|
|
<p x-show="activeSpeakerCount() >= 2" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
|
|
</div>
|
|
</template>
|
|
</x-slot:subtitle>
|
|
<x-slot:header>
|
|
<h2 class="text-base font-semibold text-white" x-text="usesStageLayout ? 'Speakers' : 'Participants'"></h2>
|
|
</x-slot:header>
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
<template x-if="isHost && waitingParticipants().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-amber-300">
|
|
Waiting to join
|
|
<span class="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-200"
|
|
x-text="waitingParticipants().length"></span>
|
|
</p>
|
|
<template x-for="person in waitingParticipants()" :key="'wait-'+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-amber-500/20 text-sm font-semibold text-amber-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-amber-200/80">Waiting for admission</span>
|
|
</span>
|
|
<span class="flex shrink-0 gap-1">
|
|
<button type="button" @click="admitParticipant(person.uuid)"
|
|
class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-500">Admit</button>
|
|
<button type="button" @click="denyParticipant(person.uuid)"
|
|
class="rounded-lg bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-200 hover:bg-slate-600">Deny</button>
|
|
</span>
|
|
</div>
|
|
</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' : ''">
|
|
<span class="relative flex h-10 w-10 shrink-0">
|
|
<img x-show="person.avatar_url"
|
|
:src="person.avatar_url"
|
|
alt=""
|
|
class="h-10 w-10 rounded-full object-cover"
|
|
:class="isSpeaking(person.uuid) ? 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-slate-900' : ''">
|
|
<span x-show="!person.avatar_url"
|
|
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold text-white"
|
|
:class="isSpeaking(person.uuid) ? 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-slate-900' : ''"
|
|
:style="avatarStyle(person.display_name)"
|
|
x-text="participantInitials(person.display_name)"></span>
|
|
</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 x-show="isSpeaking(person.uuid)" class="block text-xs font-medium text-emerald-400">Speaking</span>
|
|
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
|
</span>
|
|
<span class="flex shrink-0 items-center gap-1">
|
|
<button x-show="usesStageLayout && isHost && person.user_ref && spotlightSpeakerRef !== person.user_ref"
|
|
type="button" @click="setSpotlight(person.user_ref)"
|
|
class="rounded-lg bg-indigo-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-indigo-500">
|
|
Spotlight
|
|
</button>
|
|
<button x-show="isConference && isHost && isGreenRoom && person.user_ref && !['host', 'co_host', 'panelist'].includes(person.role)"
|
|
type="button" @click="makeSpeaker(person.user_ref)"
|
|
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"/>
|
|
<rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
|
|
<rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
|
|
</svg>
|
|
</span>
|
|
<span x-show="person.hand_raised" class="rounded-full bg-amber-500/20 p-1 text-amber-300" title="Hand raised">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand', 'class' => 'h-3.5 w-3.5'])
|
|
</span>
|
|
<svg x-show="person.is_muted" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" title="Muted">
|
|
<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>
|
|
<svg x-show="person.is_video_off" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" title="Camera off">
|
|
<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>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<p x-show="(usesStageLayout ? speakerParticipantsSorted().length : inCallParticipants().length) === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500" x-text="usesStageLayout ? 'No speakers yet.' : 'No participants yet.'"></p>
|
|
</div>
|
|
</x-meet.room.panel>
|
|
|
|
<div x-show="panelSetupOpen" x-cloak
|
|
class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 backdrop-blur-sm sm:items-center"
|
|
@keydown.escape.window="cancelPanelSetup()">
|
|
<div class="w-full max-w-lg rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="cancelPanelSetup()">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-white">Stage layout</h3>
|
|
<p class="mt-1 text-sm text-slate-400">Choose how speakers appear on stage. Changes apply when you save.</p>
|
|
</div>
|
|
<button @click="cancelPanelSetup()" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-800 hover:text-white">
|
|
<svg 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="M6 18 18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
|
<button type="button" @click="selectStageLayout('plenary')"
|
|
class="rounded-xl border p-4 text-left transition-colors"
|
|
:class="stageLayout === 'plenary'
|
|
? 'border-sky-500/60 bg-sky-500/10 ring-1 ring-sky-500/40'
|
|
: 'border-slate-700/60 bg-slate-950/60 hover:border-slate-600'">
|
|
<p class="text-sm font-semibold text-white">Plenary</p>
|
|
<p class="mt-1 text-xs leading-relaxed text-slate-400">One main speaker fills the stage. Other speakers and attendees appear in the strip below.</p>
|
|
</button>
|
|
<button type="button" @click="selectStageLayout('panel')"
|
|
class="rounded-xl border p-4 text-left transition-colors"
|
|
:class="stageLayout === 'panel'
|
|
? 'border-violet-500/60 bg-violet-500/10 ring-1 ring-violet-500/40'
|
|
: 'border-slate-700/60 bg-slate-950/60 hover:border-slate-600'">
|
|
<p class="text-sm font-semibold text-white">Panel discussion</p>
|
|
<p class="mt-1 text-xs leading-relaxed text-slate-400">Multiple speakers shown together in a split-screen grid. Assign speakers to each panel below.</p>
|
|
</button>
|
|
</div>
|
|
|
|
<p x-show="stageLayout === 'panel'" x-cloak class="mt-3 rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-xs text-violet-100">
|
|
Switching to panel discussion changes the live stage to a multi-speaker grid for everyone in the room.
|
|
</p>
|
|
|
|
<div x-show="stageLayout === 'panel'" x-cloak class="mt-5 space-y-3">
|
|
<template x-for="(panel, index) in panels" :key="panel.id">
|
|
<div class="rounded-xl border border-slate-700/60 bg-slate-950/60 p-3">
|
|
<div class="flex items-center gap-2">
|
|
<input type="text" x-model="panel.name" placeholder="Panel name"
|
|
class="min-w-0 flex-1 rounded-lg border-0 bg-slate-900 px-3 py-2 text-sm text-white ring-1 ring-slate-700/60">
|
|
<button type="button" @click="setActivePanel(panel.id)"
|
|
class="rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
|
:class="activePanelId === panel.id ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300'">
|
|
<span x-text="activePanelId === panel.id ? 'Active' : 'Show'"></span>
|
|
</button>
|
|
<button type="button" @click="removePanel(index)" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-red-300">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
<template x-for="speaker in speakerParticipantsSorted()" :key="panel.id + '-' + speaker.uuid">
|
|
<label class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs"
|
|
:class="panel.speaker_refs.includes(speaker.user_ref) ? 'bg-violet-600/30 text-violet-100 ring-1 ring-violet-500/50' : 'bg-slate-800 text-slate-300'">
|
|
<input type="checkbox" class="sr-only"
|
|
:checked="panel.speaker_refs.includes(speaker.user_ref)"
|
|
@change="togglePanelSpeaker(panel, speaker.user_ref)">
|
|
<span x-text="speaker.display_name"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<button type="button" @click="addPanel()"
|
|
class="w-full rounded-xl border border-dashed border-slate-600 px-3 py-2.5 text-sm font-medium text-slate-300 hover:border-violet-500/50 hover:text-violet-200">
|
|
+ Add panel
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-2">
|
|
<button type="button" @click="cancelPanelSetup()"
|
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
|
|
<button type="button" @click="saveStageLayout()" :disabled="savingStageLayout"
|
|
class="rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-60">
|
|
<span x-text="savingStageLayout ? 'Saving…' : 'Save layout'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<x-meet.room.panel show="sidebarPanel === 'features'" title="More options">
|
|
<x-slot:subtitle>
|
|
<p>{{ $sessionNounTitle }} tools and extras</p>
|
|
</x-slot:subtitle>
|
|
|
|
<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="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"
|
|
@click="sendReaction('thumbs'); closeSidebar()" />
|
|
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
|
|
@click="sendReaction('heart'); closeSidebar()" />
|
|
<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>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
|
|
<x-meet.room.menu-item icon="programme" title="Programme" :subtitle="'View the '.$sessionNoun.' lineup from Events'"
|
|
@click="toggleProgramme()" />
|
|
|
|
<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">
|
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'upload-file', 'class' => 'h-5 w-5'])
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block font-medium">Share files</span>
|
|
<span class="block text-xs text-slate-400">Upload a file for everyone in the call</span>
|
|
</span>
|
|
</button>
|
|
|
|
@if ($participant->isHost())
|
|
<button @click="toggleRecordingFromMenu()" 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">
|
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-red-500/15">
|
|
<span class="h-3 w-3 rounded-full" :class="isRecording ? 'bg-red-500 animate-pulse' : 'bg-red-400'"></span>
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block font-medium" x-text="isRecording ? 'Stop recording' : 'Start recording'"></span>
|
|
<span class="block text-xs text-slate-400">Save this {{ $sessionNoun }} to the cloud</span>
|
|
</span>
|
|
</button>
|
|
|
|
<button @click="toggleLockFromMenu()" 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">
|
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
|
@include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block font-medium" x-text="isLocked ? 'Unlock ' + sessionNoun() : 'Lock ' + sessionNoun()"></span>
|
|
<span class="block text-xs text-slate-400">Control who can join</span>
|
|
</span>
|
|
</button>
|
|
|
|
<x-meet.room.menu-item icon="start-breakout" title="Start breakouts" subtitle="Split participants into smaller rooms"
|
|
@click="createBreakouts()" />
|
|
|
|
<x-meet.room.menu-item icon="close-breakout" title="Close breakouts" subtitle="Bring everyone back to the main room"
|
|
@click="closeBreakouts()" />
|
|
|
|
<div x-show="breakoutsActive" x-cloak class="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
<p class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Broadcast to breakouts</p>
|
|
<form @submit.prevent="broadcastToBreakouts()" class="flex gap-2">
|
|
<input type="text" x-model="breakoutBroadcastInput" placeholder="Message all breakout rooms…"
|
|
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
|
|
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">Send</button>
|
|
</form>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($isWebinar ?? false)
|
|
<div class="mt-4 border-t border-slate-800 pt-4">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Q&A</p>
|
|
<ul class="mt-2 max-h-36 space-y-2 overflow-y-auto">
|
|
<template x-for="item in qaItems" :key="item.uuid">
|
|
<li class="rounded-lg bg-slate-800/80 px-3 py-2 text-sm">
|
|
<span class="font-medium text-sky-300" x-text="item.asker_name"></span>
|
|
<span class="text-slate-300" x-text="': ' + item.question"></span>
|
|
</li>
|
|
</template>
|
|
<li x-show="qaItems.length === 0" class="text-xs text-slate-500">No questions yet.</li>
|
|
</ul>
|
|
<form @submit.prevent="submitQuestion" class="mt-3 flex gap-2">
|
|
<input type="text" x-model="qaInput" placeholder="Ask a question…"
|
|
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
|
|
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium hover:bg-indigo-500">Send</button>
|
|
</form>
|
|
</div>
|
|
@endif
|
|
|
|
<p x-show="breakoutBroadcast" class="mt-3 rounded-xl bg-amber-950/40 px-3 py-3 text-sm text-amber-100" x-text="breakoutBroadcast"></p>
|
|
</div>
|
|
</x-meet.room.panel>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|