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:
@@ -63,6 +63,7 @@ MEET_API_KEY_SUPPORT=
|
|||||||
MEET_API_KEY_INVOICE=
|
MEET_API_KEY_INVOICE=
|
||||||
|
|
||||||
# --- Ladill Events (registration for webinars & conferences) ---
|
# --- Ladill Events (registration for webinars & conferences) ---
|
||||||
|
# Same secret on both apps: MEET_API_KEY_EVENTS here, EVENTS_API_KEY_MEET in ladill-events.
|
||||||
LADILL_EVENTS_APP_URL=https://events.ladill.com
|
LADILL_EVENTS_APP_URL=https://events.ladill.com
|
||||||
LADILL_EVENTS_API_URL=https://events.ladill.com/api/service/v1
|
LADILL_EVENTS_API_URL=https://events.ladill.com/api/service/v1
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class EventsLinkController extends Controller
|
|||||||
if (! $this->eventsClient->isConfigured()) {
|
if (! $this->eventsClient->isConfigured()) {
|
||||||
return redirect()
|
return redirect()
|
||||||
->route($room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show', $room)
|
->route($room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show', $room)
|
||||||
->withErrors(['events' => 'Ladill Events integration is not configured on this Meet instance.']);
|
->withErrors(['events' => 'Ladill Events linking is not configured. Set MEET_API_KEY_EVENTS in Meet (and the matching EVENTS_API_KEY_MEET in Events), then try again.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$events = $this->links->linkableEvents($request->user());
|
$events = $this->links->linkableEvents($request->user());
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ class MeetingRoomController extends Controller
|
|||||||
$authUser = $request->user();
|
$authUser = $request->user();
|
||||||
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
|
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
|
||||||
$canManageConference = $participant->isHost() || $isRoomHost;
|
$canManageConference = $participant->isHost() || $isRoomHost;
|
||||||
|
$canManageSpeakAccess = app(\App\Services\Meet\SpeakAccessService::class)->canManageSpeakAccess($participant);
|
||||||
|
$usesSpeakAccess = $room->isWebinar() || $room->isConference();
|
||||||
|
$mediaState = $this->sessions->mediaSessionState($participant);
|
||||||
$breakoutService = app(BreakoutService::class);
|
$breakoutService = app(BreakoutService::class);
|
||||||
$inBreakout = $breakoutService->isInActiveBreakout($participant);
|
$inBreakout = $breakoutService->isInActiveBreakout($participant);
|
||||||
|
|
||||||
@@ -97,7 +100,12 @@ class MeetingRoomController extends Controller
|
|||||||
'presenterRefs' => (array) $session->presenter_refs,
|
'presenterRefs' => (array) $session->presenter_refs,
|
||||||
'isAudioOnly' => $isAudioOnly,
|
'isAudioOnly' => $isAudioOnly,
|
||||||
'isSpace' => $room->isSpace(),
|
'isSpace' => $room->isSpace(),
|
||||||
'canPublish' => $this->sessions->canParticipantPublish($participant),
|
'canPublish' => $mediaState['can_publish'],
|
||||||
|
'audioOnlyPublish' => $mediaState['audio_only_publish'],
|
||||||
|
'speakGranted' => $mediaState['speak_granted'],
|
||||||
|
'speakRequested' => $mediaState['speak_requested'],
|
||||||
|
'usesSpeakAccess' => $usesSpeakAccess,
|
||||||
|
'canManageSpeakAccess' => $canManageSpeakAccess,
|
||||||
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
|
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
|
||||||
'usesStageLayout' => $stageLayout->usesStageLayout($room) && ! $inBreakout,
|
'usesStageLayout' => $stageLayout->usesStageLayout($room) && ! $inBreakout,
|
||||||
'inBreakout' => $inBreakout,
|
'inBreakout' => $inBreakout,
|
||||||
@@ -186,6 +194,62 @@ class MeetingRoomController extends Controller
|
|||||||
return response()->json(['hand_raised' => $participant->hand_raised]);
|
return response()->json(['hand_raised' => $participant->hand_raised]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requestSpeak(Request $request, Session $session): JsonResponse
|
||||||
|
{
|
||||||
|
$participant = $this->currentParticipant($request, $session);
|
||||||
|
$speak = app(\App\Services\Meet\SpeakAccessService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'participant' => $speak->requestSpeak($participant),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelSpeakRequest(Request $request, Session $session): JsonResponse
|
||||||
|
{
|
||||||
|
$participant = $this->currentParticipant($request, $session);
|
||||||
|
$speak = app(\App\Services\Meet\SpeakAccessService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'participant' => $speak->cancelSpeakRequest($participant),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantSpeak(Request $request, Session $session, Participant $participant): JsonResponse
|
||||||
|
{
|
||||||
|
$actor = $this->currentParticipant($request, $session);
|
||||||
|
abort_unless($participant->session_id === $session->id, 404);
|
||||||
|
|
||||||
|
$speak = app(\App\Services\Meet\SpeakAccessService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'participant' => $speak->grantSpeak($participant, $actor),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeSpeak(Request $request, Session $session, Participant $participant): JsonResponse
|
||||||
|
{
|
||||||
|
$actor = $this->currentParticipant($request, $session);
|
||||||
|
abort_unless($participant->session_id === $session->id, 404);
|
||||||
|
|
||||||
|
$speak = app(\App\Services\Meet\SpeakAccessService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'participant' => $speak->revokeSpeak($participant, $actor),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismissSpeakRequest(Request $request, Session $session, Participant $participant): JsonResponse
|
||||||
|
{
|
||||||
|
$actor = $this->currentParticipant($request, $session);
|
||||||
|
abort_unless($participant->session_id === $session->id, 404);
|
||||||
|
|
||||||
|
$speak = app(\App\Services\Meet\SpeakAccessService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'participant' => $speak->dismissSpeakRequest($participant, $actor),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function sendMessage(Request $request, Session $session): JsonResponse
|
public function sendMessage(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
$participant = $this->currentParticipant($request, $session);
|
$participant = $this->currentParticipant($request, $session);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class Participant extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'uuid', 'owner_ref', 'session_id', 'user_ref', 'display_name', 'email',
|
'uuid', 'owner_ref', 'session_id', 'user_ref', 'display_name', 'email',
|
||||||
'role', 'status', 'joined_at', 'left_at', 'device_info', 'ip_address',
|
'role', 'status', 'joined_at', 'left_at', 'device_info', 'ip_address',
|
||||||
'hand_raised', 'is_muted', 'is_video_off', 'breakout_room_id',
|
'hand_raised', 'speak_requested', 'speak_granted', 'is_muted', 'is_video_off', 'breakout_room_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -25,6 +25,8 @@ class Participant extends Model
|
|||||||
'joined_at' => 'datetime',
|
'joined_at' => 'datetime',
|
||||||
'left_at' => 'datetime',
|
'left_at' => 'datetime',
|
||||||
'hand_raised' => 'boolean',
|
'hand_raised' => 'boolean',
|
||||||
|
'speak_requested' => 'boolean',
|
||||||
|
'speak_granted' => 'boolean',
|
||||||
'is_muted' => 'boolean',
|
'is_muted' => 'boolean',
|
||||||
'is_video_off' => 'boolean',
|
'is_video_off' => 'boolean',
|
||||||
];
|
];
|
||||||
@@ -66,9 +68,18 @@ class Participant extends Model
|
|||||||
|
|
||||||
public function canPublishMedia(): bool
|
public function canPublishMedia(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->speak_granted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->role !== 'attendee';
|
return $this->role !== 'attendee';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasSpeakAccess(): bool
|
||||||
|
{
|
||||||
|
return $this->speak_granted;
|
||||||
|
}
|
||||||
|
|
||||||
public function breakoutRoom(): BelongsTo
|
public function breakoutRoom(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id');
|
return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id');
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ class ConferenceService
|
|||||||
|
|
||||||
public function canPublish(Room $room, Participant $participant): bool
|
public function canPublish(Room $room, Participant $participant): bool
|
||||||
{
|
{
|
||||||
|
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($room->isTownHall() || $room->isWebinar()) {
|
||||||
|
return (bool) $participant->speak_granted;
|
||||||
|
}
|
||||||
|
|
||||||
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
|
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class ParticipantPresenter
|
|||||||
'role' => $participant->role,
|
'role' => $participant->role,
|
||||||
'status' => $participant->status,
|
'status' => $participant->status,
|
||||||
'hand_raised' => $participant->hand_raised,
|
'hand_raised' => $participant->hand_raised,
|
||||||
|
'speak_requested' => (bool) $participant->speak_requested,
|
||||||
|
'speak_granted' => (bool) $participant->speak_granted,
|
||||||
'is_muted' => $participant->is_muted,
|
'is_muted' => $participant->is_muted,
|
||||||
'is_video_off' => $participant->is_video_off,
|
'is_video_off' => $participant->is_video_off,
|
||||||
'breakout_room_id' => $participant->breakout_room_id,
|
'breakout_room_id' => $participant->breakout_room_id,
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ class SessionService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{can_publish: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
|
/** @return array{can_publish: bool, audio_only_publish: bool, speak_granted: bool, speak_requested: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
|
||||||
public function mediaSessionState(Participant $participant): array
|
public function mediaSessionState(Participant $participant): array
|
||||||
{
|
{
|
||||||
$participant->loadMissing(['breakoutRoom', 'session.room']);
|
$participant->loadMissing(['breakoutRoom', 'session.room']);
|
||||||
@@ -350,6 +350,11 @@ class SessionService
|
|||||||
$breakoutService = app(BreakoutService::class);
|
$breakoutService = app(BreakoutService::class);
|
||||||
$inBreakout = $breakoutService->isInActiveBreakout($participant);
|
$inBreakout = $breakoutService->isInActiveBreakout($participant);
|
||||||
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
|
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
|
||||||
|
$canPublish = $this->canParticipantPublish($participant, $inBreakout);
|
||||||
|
$audioOnlyPublish = $canPublish
|
||||||
|
&& $participant->isAttendee()
|
||||||
|
&& (bool) $participant->speak_granted
|
||||||
|
&& ! $inBreakout;
|
||||||
|
|
||||||
$breakout = null;
|
$breakout = null;
|
||||||
if ($inBreakout && $participant->breakoutRoom) {
|
if ($inBreakout && $participant->breakoutRoom) {
|
||||||
@@ -361,7 +366,10 @@ class SessionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'can_publish' => $this->canParticipantPublish($participant, $inBreakout),
|
'can_publish' => $canPublish,
|
||||||
|
'audio_only_publish' => $audioOnlyPublish,
|
||||||
|
'speak_granted' => (bool) $participant->speak_granted,
|
||||||
|
'speak_requested' => (bool) $participant->speak_requested,
|
||||||
'uses_stage_layout' => $usesStageLayout,
|
'uses_stage_layout' => $usesStageLayout,
|
||||||
'in_breakout' => $inBreakout,
|
'in_breakout' => $inBreakout,
|
||||||
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
|
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Meet;
|
||||||
|
|
||||||
|
use App\Models\Participant;
|
||||||
|
use App\Models\Room;
|
||||||
|
|
||||||
|
class SpeakAccessService
|
||||||
|
{
|
||||||
|
public function usesSpeakAccess(Room $room): bool
|
||||||
|
{
|
||||||
|
return $room->isWebinar() || $room->isConference();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canManageSpeakAccess(Participant $participant): bool
|
||||||
|
{
|
||||||
|
$room = $participant->session->room;
|
||||||
|
|
||||||
|
if (! $this->usesSpeakAccess($room)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestSpeak(Participant $participant): Participant
|
||||||
|
{
|
||||||
|
$room = $participant->session->room;
|
||||||
|
abort_unless($this->usesSpeakAccess($room), 422);
|
||||||
|
abort_unless($participant->role === 'attendee', 422);
|
||||||
|
abort_unless(! $participant->speak_granted, 422, 'You already have speak access.');
|
||||||
|
|
||||||
|
$participant->update(['speak_requested' => true]);
|
||||||
|
|
||||||
|
return $participant->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelSpeakRequest(Participant $participant): Participant
|
||||||
|
{
|
||||||
|
$participant->update(['speak_requested' => false]);
|
||||||
|
|
||||||
|
return $participant->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantSpeak(Participant $target, Participant $actor): Participant
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageSpeakAccess($actor), 403);
|
||||||
|
abort_unless($target->session_id === $actor->session_id, 404);
|
||||||
|
abort_unless($target->status === 'joined', 422);
|
||||||
|
abort_unless($this->usesSpeakAccess($target->session->room), 422);
|
||||||
|
|
||||||
|
$target->update([
|
||||||
|
'speak_granted' => true,
|
||||||
|
'speak_requested' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $target->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeSpeak(Participant $target, Participant $actor): Participant
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageSpeakAccess($actor), 403);
|
||||||
|
abort_unless($target->session_id === $actor->session_id, 404);
|
||||||
|
|
||||||
|
$target->update([
|
||||||
|
'speak_granted' => false,
|
||||||
|
'speak_requested' => false,
|
||||||
|
'is_muted' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $target->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismissSpeakRequest(Participant $target, Participant $actor): Participant
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageSpeakAccess($actor), 403);
|
||||||
|
abort_unless($target->session_id === $actor->session_id, 404);
|
||||||
|
|
||||||
|
$target->update(['speak_requested' => false]);
|
||||||
|
|
||||||
|
return $target->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@ class WebinarService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
|
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $participant->speak_granted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ class EventsSourceLink
|
|||||||
return rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
|
return rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isApiConfigured(): bool
|
||||||
|
{
|
||||||
|
return filled(config('meet.events_api_url')) && filled(config('meet.events_api_key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createEventUrl(?Room $room = null): string
|
||||||
|
{
|
||||||
|
return self::baseUrl().'/events/create';
|
||||||
|
}
|
||||||
|
|
||||||
public static function eventAdminUrl(Room $room): ?string
|
public static function eventAdminUrl(Room $room): ?string
|
||||||
{
|
{
|
||||||
$eventId = self::eventId($room);
|
$eventId = self::eventId($room);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('meet_participants', function (Blueprint $table) {
|
||||||
|
$table->boolean('speak_requested')->default(false)->after('hand_raised');
|
||||||
|
$table->boolean('speak_granted')->default(false)->after('speak_requested');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('meet_participants', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['speak_requested', 'speak_granted']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
|
||||||
|
<path d="M24,17.5c0,2.345-.913,4.55-2.571,6.207-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293c-.391-.391-.391-1.023,0-1.414,1.28-1.28,1.985-2.982,1.985-4.793s-.705-3.513-1.985-4.793c-.391-.391-.391-1.023,0-1.414s1.023-.391,1.414,0c1.658,1.657,2.571,3.862,2.571,6.207Zm-5.413-3.407c-.391-.391-1.023-.391-1.414,0s-.391,1.023,0,1.414c1.099,1.099,1.099,2.888,0,3.986-.391,.391-.391,1.023,0,1.414,.195,.195,.451,.293,.707,.293s.512-.098,.707-.293c1.879-1.879,1.879-4.936,0-6.814ZM1.452,.106c-.219,.029-.435,.066-.649,.109C.262,.325-.089,.852,.02,1.394c.109,.541,.631,.893,1.178,.783,.174-.035,.351-.064,.527-.089,3.314-.459,6.479,.865,8.498,3.56,1.319,1.631,3.695,6.546,3.777,7.353h-1.207c-.501,0-.926,.372-.991,.869l-.334,2.524c-.196,1.486-1.475,2.606-2.974,2.606h-1.494c-.552,0-1,.447-1,1v1c0,.552-.449,1-1,1H1c-.552,0-1,.447-1,1s.448,1,1,1H5c1.654,0,3-1.346,3-3h.494c2.499,0,4.63-1.868,4.957-4.345l.219-1.655h.33c1.299,0,2-1.03,2-2,0-1.42-2.932-7.015-4.2-8.581C9.363,1.164,5.497-.449,1.452,.106Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
+195
-4
@@ -53,6 +53,11 @@ function meetRoom() {
|
|||||||
goingLive: false,
|
goingLive: false,
|
||||||
presenterRefs: [],
|
presenterRefs: [],
|
||||||
audioOnly: false,
|
audioOnly: false,
|
||||||
|
audioOnlyPublish: false,
|
||||||
|
usesSpeakAccess: false,
|
||||||
|
canManageSpeak: false,
|
||||||
|
speakGranted: false,
|
||||||
|
speakRequested: false,
|
||||||
isSpace: false,
|
isSpace: false,
|
||||||
usesStageLayout: false,
|
usesStageLayout: false,
|
||||||
panelDiscussions: false,
|
panelDiscussions: false,
|
||||||
@@ -112,6 +117,11 @@ function meetRoom() {
|
|||||||
chatUrl: el.dataset.chatUrl,
|
chatUrl: el.dataset.chatUrl,
|
||||||
reactionUrl: el.dataset.reactionUrl,
|
reactionUrl: el.dataset.reactionUrl,
|
||||||
raiseHandUrl: el.dataset.raiseHandUrl,
|
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,
|
recordingStartUrl: el.dataset.recordingStartUrl,
|
||||||
recordingStopUrl: el.dataset.recordingStopUrl,
|
recordingStopUrl: el.dataset.recordingStopUrl,
|
||||||
lockUrl: el.dataset.lockUrl,
|
lockUrl: el.dataset.lockUrl,
|
||||||
@@ -143,6 +153,11 @@ function meetRoom() {
|
|||||||
this.isHost = el.dataset.isHost === '1';
|
this.isHost = el.dataset.isHost === '1';
|
||||||
this.config.participantUuid = el.dataset.participant || '';
|
this.config.participantUuid = el.dataset.participant || '';
|
||||||
this.canPublish = el.dataset.canPublish !== '0';
|
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.inBreakout = el.dataset.inBreakout === '1';
|
||||||
this.isWebinar = el.dataset.isWebinar === '1';
|
this.isWebinar = el.dataset.isWebinar === '1';
|
||||||
this.isConference = el.dataset.isConference === '1';
|
this.isConference = el.dataset.isConference === '1';
|
||||||
@@ -384,7 +399,7 @@ function meetRoom() {
|
|||||||
|
|
||||||
async enableLocalMedia(activeRoom, attempts = 3) {
|
async enableLocalMedia(activeRoom, attempts = 3) {
|
||||||
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
|
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 = {
|
const audioOptions = {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
@@ -1583,6 +1598,9 @@ function meetRoom() {
|
|||||||
this.inBreakout = Boolean(media.in_breakout);
|
this.inBreakout = Boolean(media.in_breakout);
|
||||||
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
|
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
|
||||||
this.canPublish = Boolean(media.can_publish);
|
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.usesStageLayout = Boolean(media.uses_stage_layout);
|
||||||
this.breakoutRoomName = media.breakout?.name || '';
|
this.breakoutRoomName = media.breakout?.name || '';
|
||||||
this.breakoutClosesAt = media.breakout?.closes_at || '';
|
this.breakoutClosesAt = media.breakout?.closes_at || '';
|
||||||
@@ -1596,6 +1614,8 @@ function meetRoom() {
|
|||||||
|
|
||||||
const previousBreakoutId = this.assignedBreakoutRoomId;
|
const previousBreakoutId = this.assignedBreakoutRoomId;
|
||||||
const previousCanPublish = this.canPublish;
|
const previousCanPublish = this.canPublish;
|
||||||
|
const previousAudioOnlyPublish = this.audioOnlyPublish;
|
||||||
|
const previousSpeakGranted = this.speakGranted;
|
||||||
const previousUsesStageLayout = this.usesStageLayout;
|
const previousUsesStageLayout = this.usesStageLayout;
|
||||||
|
|
||||||
this.applyMediaSessionState(media);
|
this.applyMediaSessionState(media);
|
||||||
@@ -1604,6 +1624,8 @@ function meetRoom() {
|
|||||||
|
|
||||||
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|
||||||
|| previousCanPublish !== this.canPublish
|
|| previousCanPublish !== this.canPublish
|
||||||
|
|| previousAudioOnlyPublish !== this.audioOnlyPublish
|
||||||
|
|| previousSpeakGranted !== this.speakGranted
|
||||||
|| previousUsesStageLayout !== this.usesStageLayout;
|
|| previousUsesStageLayout !== this.usesStageLayout;
|
||||||
|
|
||||||
if (needsReconnect && this.config.configured) {
|
if (needsReconnect && this.config.configured) {
|
||||||
@@ -1828,7 +1850,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async toggleMute() {
|
async toggleMute() {
|
||||||
if (!room || !this.canPublish) return;
|
if (!room || !this.showMicrophoneControl()) return;
|
||||||
await this.ensureAudioPlayback();
|
await this.ensureAudioPlayback();
|
||||||
|
|
||||||
const enabled = room.localParticipant.isMicrophoneEnabled;
|
const enabled = room.localParticipant.isMicrophoneEnabled;
|
||||||
@@ -1855,7 +1877,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async toggleVideo() {
|
async toggleVideo() {
|
||||||
if (!room || this.audioOnly || !this.canPublish) return;
|
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||||
await this.ensureAudioPlayback();
|
await this.ensureAudioPlayback();
|
||||||
const enabled = room.localParticipant.isCameraEnabled;
|
const enabled = room.localParticipant.isCameraEnabled;
|
||||||
await room.localParticipant.setCameraEnabled(!enabled);
|
await room.localParticipant.setCameraEnabled(!enabled);
|
||||||
@@ -1874,7 +1896,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async toggleScreenShare() {
|
async toggleScreenShare() {
|
||||||
if (!room || this.audioOnly || !this.canPublish) return;
|
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
|
||||||
this.isScreenSharing = !this.isScreenSharing;
|
this.isScreenSharing = !this.isScreenSharing;
|
||||||
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||||
if (!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() {
|
async goLive() {
|
||||||
if (!this.config.goLiveUrl || this.goingLive) {
|
if (!this.config.goLiveUrl || this.goingLive) {
|
||||||
return;
|
return;
|
||||||
@@ -2059,10 +2242,18 @@ function meetRoom() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.participants) {
|
if (data.participants) {
|
||||||
|
const previousSpeakRequestCount = this.speakRequestParticipants().length;
|
||||||
this.sessionParticipants = data.participants;
|
this.sessionParticipants = data.participants;
|
||||||
|
if (this.usesSpeakAccess) {
|
||||||
|
this.syncSelfSpeakState();
|
||||||
|
}
|
||||||
this.pruneTilesFromSession();
|
this.pruneTilesFromSession();
|
||||||
this.refreshParticipantTileAvatars();
|
this.refreshParticipantTileAvatars();
|
||||||
this.renderAudienceStrip();
|
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') {
|
if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') {
|
||||||
this.mergeStageConfig(data);
|
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>
|
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
|
||||||
</div>
|
</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'))
|
@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">
|
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
{{ session('success') }}
|
{{ session('success') }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@php use App\Support\EventsSourceLink; @endphp
|
||||||
<x-app-layout :title="'Link to Ladill Events · '.$room->title">
|
<x-app-layout :title="'Link to Ladill Events · '.$room->title">
|
||||||
<div class="mx-auto max-w-xl">
|
<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>
|
<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 === [])
|
@if ($events === [])
|
||||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
<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>
|
<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>
|
</div>
|
||||||
@else
|
@else
|
||||||
<form method="POST" action="{{ route('meet.events.link.store', $room) }}" class="mt-6 space-y-5">
|
<form method="POST" action="{{ route('meet.events.link.store', $room) }}" class="mt-6 space-y-5">
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
$eventsUrls = EventsSourceLink::urls($room);
|
$eventsUrls = EventsSourceLink::urls($room);
|
||||||
$isLinked = EventsSourceLink::isLinked($room);
|
$isLinked = EventsSourceLink::isLinked($room);
|
||||||
$eventTitle = EventsSourceLink::eventTitle($room);
|
$eventTitle = EventsSourceLink::eventTitle($room);
|
||||||
$eventsBase = EventsSourceLink::baseUrl();
|
$eventsConfigured = EventsSourceLink::isApiConfigured();
|
||||||
|
$createEventUrl = EventsSourceLink::createEventUrl($room);
|
||||||
$kind = $room->isConference() ? 'conference' : 'webinar';
|
$kind = $room->isConference() ? 'conference' : 'webinar';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@@ -32,6 +33,17 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</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)
|
@if ($isLinked)
|
||||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
@if ($eventsUrls['registration'])
|
@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>
|
<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
|
@else
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
@if ($eventsConfigured)
|
||||||
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
|
<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>
|
@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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
'start-breakout',
|
'start-breakout',
|
||||||
'upload-file',
|
'upload-file',
|
||||||
'programme',
|
'programme',
|
||||||
|
'speak',
|
||||||
];
|
];
|
||||||
$iconName = in_array($icon, $allowed, true) ? $icon : null;
|
$iconName = in_array($icon, $allowed, true) ? $icon : null;
|
||||||
$iconPath = $iconName ? public_path('images/meet-icons/'.$iconName.'.svg') : null;
|
$iconPath = $iconName ? public_path('images/meet-icons/'.$iconName.'.svg') : null;
|
||||||
|
|||||||
@@ -68,6 +68,13 @@
|
|||||||
data-chat-url="{{ route('meet.room.chat', $session) }}"
|
data-chat-url="{{ route('meet.room.chat', $session) }}"
|
||||||
data-reaction-url="{{ route('meet.room.reaction', $session) }}"
|
data-reaction-url="{{ route('meet.room.reaction', $session) }}"
|
||||||
data-raise-hand-url="{{ route('meet.room.raise-hand', $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-leave-url="{{ route('meet.room.leave', $session) }}"
|
||||||
data-end-url="{{ route('meet.room.end', $session) }}"
|
data-end-url="{{ route('meet.room.end', $session) }}"
|
||||||
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
|
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
|
||||||
@@ -99,6 +106,9 @@
|
|||||||
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
|
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
|
||||||
data-presenter-refs='@json($presenterRefs ?? [])'
|
data-presenter-refs='@json($presenterRefs ?? [])'
|
||||||
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
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-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
|
||||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
||||||
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
||||||
@@ -318,11 +328,17 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4">
|
<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 mx-auto max-w-5xl">
|
||||||
<div class="meet-toolbar__track sm:px-0">
|
<div class="meet-toolbar__track sm:px-0">
|
||||||
<div class="meet-toolbar__primary sm:contents">
|
<div class="meet-toolbar__primary sm:contents">
|
||||||
<button @click="toggleMute()" type="button"
|
<button @click="toggleMute()" type="button"
|
||||||
x-show="canPublish"
|
x-show="showMicrophoneControl()"
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
:title="isMuted ? 'Unmute' : 'Mute'">
|
: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>
|
<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>
|
||||||
<button @click="toggleVideo()" type="button"
|
<button @click="toggleVideo()" type="button"
|
||||||
x-show="canPublish && !audioOnly"
|
x-show="showVideoControl()"
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
|
: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>
|
<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>
|
||||||
<button @click="toggleScreenShare()" type="button"
|
<button @click="toggleScreenShare()" type="button"
|
||||||
x-show="canPublish && !audioOnly"
|
x-show="showVideoControl()"
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Share screen">
|
title="Share screen">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'screen-share'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'screen-share'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="raiseHand()" type="button"
|
<button @click="raiseHand()" type="button"
|
||||||
x-show="!canPublish"
|
x-show="showAudienceControls()"
|
||||||
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="Raise hand">
|
title="Raise hand">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('thumbs')" type="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"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="Applause — sound plays when others join in">
|
title="Applause — sound plays when others join in">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('heart')" type="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"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="React">
|
title="React">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||||
@@ -370,13 +386,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
||||||
</button>
|
</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'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
||||||
</button>
|
</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'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleChat()" type="button"
|
<button @click="toggleChat()" type="button"
|
||||||
@@ -564,6 +580,31 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<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"
|
<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' : ''">
|
: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">
|
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
|
||||||
Make speaker
|
Make speaker
|
||||||
</button>
|
</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>
|
||||||
<span class="flex shrink-0 items-center gap-1 text-slate-400">
|
<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">
|
<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">
|
<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--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="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<div class="space-y-1">
|
<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"
|
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
|
||||||
@click="raiseHand(); closeSidebar()" />
|
@click="raiseHand(); closeSidebar()" />
|
||||||
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
|
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
|
||||||
@@ -720,7 +775,40 @@
|
|||||||
@click="toggleChat()" />
|
@click="toggleChat()" />
|
||||||
<div class="my-1 border-t border-slate-800"></div>
|
<div class="my-1 border-t border-slate-800"></div>
|
||||||
</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"
|
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||||
@click="toggleChat()" />
|
@click="toggleChat()" />
|
||||||
<div class="my-1 border-t border-slate-800"></div>
|
<div class="my-1 border-t border-slate-800"></div>
|
||||||
|
|||||||
@@ -14,12 +14,6 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</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'))
|
@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">
|
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
{{ session('success') }}
|
{{ session('success') }}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
['name' => 'Conferences', 'route' => route('meet.conferences.index'), 'active' => request()->routeIs('meet.conferences.*'),
|
['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" />'],
|
'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.*'),
|
['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.*'),
|
['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" />'],
|
'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.*'),
|
['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">
|
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||||
@foreach ($nav as $item)
|
@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' }}">
|
<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']))
|
<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>
|
||||||
@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
|
|
||||||
<span>{{ $item['name'] }}</span>
|
<span>{{ $item['name'] }}</span>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ Route::post('/room/{session}/leave', [MeetingRoomController::class, 'leave'])->n
|
|||||||
Route::post('/room/{session}/participants/{participant}/admit', [MeetingRoomController::class, 'admit'])->name('meet.room.admit');
|
Route::post('/room/{session}/participants/{participant}/admit', [MeetingRoomController::class, 'admit'])->name('meet.room.admit');
|
||||||
Route::post('/room/{session}/participants/{participant}/deny', [MeetingRoomController::class, 'deny'])->name('meet.room.deny');
|
Route::post('/room/{session}/participants/{participant}/deny', [MeetingRoomController::class, 'deny'])->name('meet.room.deny');
|
||||||
Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand');
|
Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand');
|
||||||
|
Route::post('/room/{session}/speak/request', [MeetingRoomController::class, 'requestSpeak'])->name('meet.room.speak.request');
|
||||||
|
Route::post('/room/{session}/speak/cancel', [MeetingRoomController::class, 'cancelSpeakRequest'])->name('meet.room.speak.cancel');
|
||||||
|
Route::post('/room/{session}/participants/{participant}/speak/grant', [MeetingRoomController::class, 'grantSpeak'])->name('meet.room.speak.grant');
|
||||||
|
Route::post('/room/{session}/participants/{participant}/speak/revoke', [MeetingRoomController::class, 'revokeSpeak'])->name('meet.room.speak.revoke');
|
||||||
|
Route::post('/room/{session}/participants/{participant}/speak/dismiss', [MeetingRoomController::class, 'dismissSpeakRequest'])->name('meet.room.speak.dismiss');
|
||||||
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
|
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
|
||||||
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
|
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
|
||||||
Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
|
Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
|
||||||
|
|||||||
@@ -1368,6 +1368,132 @@ class MeetWebTest extends TestCase
|
|||||||
->assertSee('data-session-noun="webinar"', false);
|
->assertSee('data-session-noun="webinar"', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_webinar_attendee_can_request_and_receive_speak_access(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx',
|
||||||
|
'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'Town hall',
|
||||||
|
'type' => 'webinar',
|
||||||
|
'status' => 'live',
|
||||||
|
'scheduled_at' => now()->addHour(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => config('meet.default_settings'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'meet-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'session_mode' => 'live',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$host = \App\Models\Participant::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'display_name' => 'Host',
|
||||||
|
'role' => 'host',
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attendee = \App\Models\Participant::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'display_name' => 'Attendee',
|
||||||
|
'role' => 'attendee',
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sessions = app(\App\Services\Meet\SessionService::class);
|
||||||
|
$this->assertFalse($sessions->canParticipantPublish($attendee));
|
||||||
|
|
||||||
|
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
|
||||||
|
->get('/room/'.$session->uuid)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Request to speak', false)
|
||||||
|
->assertSee('meet-icons/speak.svg', false)
|
||||||
|
->assertSee('data-uses-speak-access="1"', false);
|
||||||
|
|
||||||
|
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
|
||||||
|
->postJson(route('meet.room.speak.request', $session))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('participant.speak_requested', true);
|
||||||
|
|
||||||
|
$attendee->refresh();
|
||||||
|
$this->assertTrue($attendee->speak_requested);
|
||||||
|
$this->assertFalse($attendee->speak_granted);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
|
||||||
|
->postJson(route('meet.room.speak.grant', [$session, $attendee]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('participant.speak_granted', true)
|
||||||
|
->assertJsonPath('participant.speak_requested', false);
|
||||||
|
|
||||||
|
$attendee->refresh();
|
||||||
|
$this->assertTrue($attendee->speak_granted);
|
||||||
|
$this->assertTrue($sessions->canParticipantPublish($attendee));
|
||||||
|
|
||||||
|
$mediaState = $sessions->mediaSessionState($attendee);
|
||||||
|
$this->assertTrue($mediaState['can_publish']);
|
||||||
|
$this->assertTrue($mediaState['audio_only_publish']);
|
||||||
|
$this->assertTrue($mediaState['speak_granted']);
|
||||||
|
|
||||||
|
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
|
||||||
|
->get('/room/'.$session->uuid)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('You can speak', false)
|
||||||
|
->assertSee('data-speak-granted="1"', false);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
|
||||||
|
->postJson(route('meet.room.speak.revoke', [$session, $attendee]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('participant.speak_granted', false);
|
||||||
|
|
||||||
|
$attendee->refresh();
|
||||||
|
$this->assertFalse($sessions->canParticipantPublish($attendee));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_webinar_show_events_create_link_uses_events_create_path(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'meet.events_app_url' => 'https://events.test',
|
||||||
|
'meet.events_api_url' => 'https://events.test/api/service/v1',
|
||||||
|
'meet.events_api_key' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'Sales webinar',
|
||||||
|
'type' => 'webinar',
|
||||||
|
'status' => 'ended',
|
||||||
|
'scheduled_at' => now()->subDay(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => config('meet.default_settings'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get(route('meet.webinars.show', $room))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('https://events.test/events/create', false)
|
||||||
|
->assertSee('Events linking is not configured on this Meet instance.', false)
|
||||||
|
->assertDontSee('/qr-codes/create', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_afia_chat_returns_reply_when_ai_configured(): void
|
public function test_afia_chat_returns_reply_when_ai_configured(): void
|
||||||
{
|
{
|
||||||
config([
|
config([
|
||||||
|
|||||||
Reference in New Issue
Block a user