diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php new file mode 100644 index 0000000..d6e301d --- /dev/null +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -0,0 +1,165 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('type', 'town_hall'); + + $this->scopeToBranch($request, $query); + + $conferences = $query->orderByDesc('scheduled_at')->paginate(20); + + return view('meet.conferences.index', compact('conferences', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $templates = Template::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + return view('meet.conferences.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'timezones' => timezone_identifiers_list(), + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'scheduled_at' => ['required', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], + 'invite_emails' => ['nullable', 'string'], + 'waiting_room' => ['sometimes', 'boolean'], + 'join_before_host' => ['sometimes', 'boolean'], + 'mute_on_join' => ['sometimes', 'boolean'], + 'auto_record' => ['sometimes', 'boolean'], + 'live_captions' => ['sometimes', 'boolean'], + 'green_room' => ['sometimes', 'boolean'], + 'practice_mode' => ['sometimes', 'boolean'], + ]); + + $settings = [ + 'waiting_room' => $request->boolean('waiting_room', true), + 'join_before_host' => $request->boolean('join_before_host'), + 'mute_on_join' => $request->boolean('mute_on_join', true), + 'auto_record' => $request->boolean('auto_record'), + 'live_captions' => $request->boolean('live_captions'), + 'green_room' => $request->boolean('green_room', true), + 'practice_mode' => $request->boolean('practice_mode'), + 'stage_mode' => true, + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + 'type' => 'town_hall', + 'status' => 'scheduled', + ]); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $room = $this->rooms->create($request->user(), $organization, $data); + + if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { + $invites = collect(preg_split('/[\s,;]+/', $emails)) + ->filter() + ->map(fn ($email) => ['email' => $email]) + ->all(); + $this->invitations->inviteToRoom($room, $invites, $request->user()); + } + + if ($room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + abort_unless($room->isConference(), 404); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); + + return view('meet.conferences.show', compact('room')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_unless($room->isConference(), 404); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.conferences.show', $room) + ->withErrors(['start' => 'Conference was cancelled.']); + } + + if ($room->setting('green_room')) { + $session = $this->townHalls->startGreenRoom($room, $request->user()); + } else { + $session = $this->sessions->start($room, $request->user()); + } + + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to join as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index ebf3324..3659980 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -9,6 +9,7 @@ use App\Models\Session; use App\Services\Meet\InvitationService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; +use App\Services\Meet\SpaceService; use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -22,6 +23,7 @@ class JoinController extends Controller protected SessionService $sessions, protected SecurityPolicyService $security, protected WebinarService $webinars, + protected SpaceService $spaces, ) {} public function show(Request $request, Room $room): View|RedirectResponse @@ -155,7 +157,9 @@ class JoinController extends Controller $role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest'; - if ($room->isWebinar() && $role !== 'host') { + if ($room->isSpace()) { + $role = $this->spaces->resolveJoinRole($room, $user, $role); + } elseif ($room->isWebinar() && $role !== 'host') { $token = $request->session()->get("meet.webinar.{$room->uuid}"); $reg = $token ? $this->webinars->findByAccessToken($token) diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 4a71351..fbe27fe 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -12,7 +12,9 @@ use App\Services\Meet\ParticipantPresenter; use App\Services\Meet\RecordingService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; +use App\Services\Meet\SpaceService; use App\Services\Meet\TranscriptService; +use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -54,19 +56,28 @@ class MeetingRoomController extends Controller $session->load(['room', 'participants']); + $room = $session->room; + $webinars = app(WebinarService::class); + $spaces = app(SpaceService::class); + $isAudioOnly = $room->isAudioOnly(); + return view('meet.room.show', [ 'session' => $session, - 'room' => $session->room, + 'room' => $room, 'participant' => $participant, 'mediaToken' => $token, 'mediaUrl' => $this->media->serverUrl(), 'mediaConfigured' => $this->media->isConfigured(), 'messages' => $this->chat->recentMessages($session), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), - 'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false), - 'isWebinar' => $session->room->isWebinar(), - 'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant), - 'isAttendee' => $participant->isAttendee(), + 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), + 'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(), + 'isAudioOnly' => $isAudioOnly, + 'isSpace' => $room->isSpace(), + 'canPublish' => $room->isSpace() + ? $spaces->canPublish($room, $participant) + : $webinars->canPublish($room, $participant), + 'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant), ]); } diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php index 4e40807..f824054 100644 --- a/app/Http/Controllers/Meet/RoomController.php +++ b/app/Http/Controllers/Meet/RoomController.php @@ -45,7 +45,7 @@ class RoomController extends Controller $owner = $this->ownerRef($request); $query = Room::owned($owner)->where('organization_id', $organization->id) - ->where('type', '!=', 'webinar'); + ->whereNotIn('type', ['webinar', 'town_hall', 'space']); $this->scopeToBranch($request, $query); $rooms = $query->orderByDesc('scheduled_at')->paginate(20); @@ -80,7 +80,7 @@ class RoomController extends Controller $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], - 'type' => ['nullable', 'string', 'in:scheduled,town_hall'], + 'type' => ['nullable', 'string', 'in:scheduled'], 'scheduled_at' => ['nullable', 'date'], 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], 'timezone' => ['nullable', 'timezone'], @@ -122,16 +122,6 @@ class RoomController extends Controller if (! empty($validated['recurrence_rule']) && ! $isInstant) { $room = $this->recurring->createSeries($request->user(), $organization, $data); - } elseif (($validated['type'] ?? '') === 'town_hall') { - abort_if($isInstant, 422, 'Town halls must be scheduled.'); - $room = $this->rooms->create($request->user(), $organization, array_merge($data, [ - 'type' => 'town_hall', - 'status' => 'scheduled', - 'settings' => array_merge($settings, [ - 'green_room' => true, - 'practice_mode' => $request->boolean('practice_mode'), - ]), - ])); } elseif ($isInstant) { $room = $this->rooms->createInstant($request->user(), $organization, $data); } else { @@ -162,6 +152,14 @@ class RoomController extends Controller return redirect()->route('meet.webinars.show', $room); } + if ($room->isConference()) { + return redirect()->route('meet.conferences.show', $room); + } + + if ($room->isSpace()) { + return redirect()->route('meet.spaces.show', $room); + } + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); return view('meet.rooms.show', compact('room')); diff --git a/app/Http/Controllers/Meet/SpaceController.php b/app/Http/Controllers/Meet/SpaceController.php new file mode 100644 index 0000000..30e1cee --- /dev/null +++ b/app/Http/Controllers/Meet/SpaceController.php @@ -0,0 +1,182 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('type', 'space'); + + $this->scopeToBranch($request, $query); + + $spaces = $query->orderByDesc('updated_at')->paginate(20); + + return view('meet.spaces.index', compact('spaces', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $templates = Template::owned($owner) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + $members = Member::owned($owner) + ->where('organization_id', $organization->id) + ->where('user_ref', '!=', $owner) + ->orderBy('user_ref') + ->get(); + + return view('meet.spaces.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'members' => $members, + 'timezones' => timezone_identifiers_list(), + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'scheduled_at' => ['nullable', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], + 'invite_emails' => ['nullable', 'string'], + 'speaker_refs' => ['nullable', 'array'], + 'speaker_refs.*' => ['string', 'max:64'], + 'invite_only' => ['sometimes', 'boolean'], + 'mute_listeners' => ['sometimes', 'boolean'], + ]); + + $speakerRefs = collect($validated['speaker_refs'] ?? []) + ->filter() + ->unique() + ->values() + ->all(); + + $settings = [ + 'waiting_room' => false, + 'join_before_host' => false, + 'mute_on_join' => $request->boolean('mute_listeners', true), + 'video_off_on_join' => true, + 'audio_only' => true, + 'space_mode' => true, + 'stage_mode' => true, + 'invite_only' => $request->boolean('invite_only', true), + 'speaker_refs' => $speakerRefs, + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + 'type' => 'space', + 'status' => empty($validated['scheduled_at']) ? 'scheduled' : 'scheduled', + ]); + + unset($data['speaker_refs'], $data['invite_only'], $data['mute_listeners']); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $room = $this->rooms->create($request->user(), $organization, $data); + + if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { + $invites = collect(preg_split('/[\s,;]+/', $emails)) + ->filter() + ->map(fn ($email) => ['email' => $email]) + ->all(); + $this->invitations->inviteToRoom($room, $invites, $request->user()); + } + + if (! empty($room->scheduled_at)) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.spaces.show', $room)->with('success', 'Room created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + abort_unless($room->isSpace(), 404); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); + + $speakerRefs = (array) $room->setting('speaker_refs', []); + $speakers = Member::owned($this->ownerRef($request)) + ->where('organization_id', $room->organization_id) + ->whereIn('user_ref', $speakerRefs) + ->get(); + + return view('meet.spaces.show', compact('room', 'speakers')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_unless($room->isSpace(), 404); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.spaces.show', $room) + ->withErrors(['start' => 'Room was closed.']); + } + + $session = $this->sessions->start($room, $request->user()); + + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to open room as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index bc76ea1..45918bf 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -81,6 +81,21 @@ class Room extends Model return $this->type === 'webinar'; } + public function isConference(): bool + { + return $this->type === 'town_hall'; + } + + public function isSpace(): bool + { + return $this->type === 'space'; + } + + public function isAudioOnly(): bool + { + return $this->isSpace() || (bool) $this->setting('audio_only', false); + } + public function canRestart(): bool { if ($this->status === 'cancelled' || $this->activeSession()) { @@ -100,6 +115,14 @@ class Room extends Model return $restarting ? 'Restart webinar' : 'Start webinar'; } + if ($this->isConference()) { + return $restarting ? 'Restart conference' : 'Start conference'; + } + + if ($this->isSpace()) { + return $restarting ? 'Reopen room' : 'Open room'; + } + return $restarting ? 'Restart meeting' : 'Start meeting'; } diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index 7a8c0a9..1aa9867 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -262,8 +262,11 @@ class SessionService $isHost = $participant->isHost(); $breakoutService = app(BreakoutService::class); $webinarService = app(WebinarService::class); + $spaceService = app(\App\Services\Meet\SpaceService::class); $mediaRoom = $breakoutService->mediaRoomForParticipant($participant); - $canPublish = $webinarService->canPublish($room, $participant) && $participant->canPublishMedia(); + $canPublish = ($room->isSpace() + ? $spaceService->canPublish($room, $participant) + : $webinarService->canPublish($room, $participant)) && $participant->canPublishMedia(); return $this->media->generateToken( $mediaRoom, diff --git a/app/Services/Meet/SpaceService.php b/app/Services/Meet/SpaceService.php new file mode 100644 index 0000000..501da70 --- /dev/null +++ b/app/Services/Meet/SpaceService.php @@ -0,0 +1,51 @@ +type !== 'space') { + return $fallbackRole; + } + + if ($user && $user->ownerRef() === $room->host_user_ref) { + return 'host'; + } + + $speakerRefs = (array) $room->setting('speaker_refs', []); + + if ($user && in_array($user->ownerRef(), $speakerRefs, true)) { + return 'panelist'; + } + + return 'attendee'; + } + + public function canPublish(Room $room, Participant $participant): bool + { + if ($room->type !== 'space') { + return true; + } + + return in_array($participant->role, ['host', 'co_host', 'panelist'], true); + } + + public function isListener(Room $room, Participant $participant): bool + { + return $room->type === 'space' && $participant->role === 'attendee'; + } + + public function isAudioOnly(Room $room): bool + { + return $room->isSpace() || (bool) $room->setting('audio_only', false); + } +} diff --git a/app/Services/Meet/WebinarService.php b/app/Services/Meet/WebinarService.php index 68039ce..8d3cd24 100644 --- a/app/Services/Meet/WebinarService.php +++ b/app/Services/Meet/WebinarService.php @@ -63,7 +63,7 @@ class WebinarService public function canPublish(Room $room, Participant $participant): bool { - if ($room->type !== 'webinar') { + if (! in_array($room->type, ['webinar', 'town_hall'], true)) { return true; } diff --git a/config/meet.php b/config/meet.php index 16ac940..a6b5001 100644 --- a/config/meet.php +++ b/config/meet.php @@ -16,7 +16,8 @@ return [ 'personal' => 'Personal room', 'recurring' => 'Recurring meeting', 'webinar' => 'Webinar', - 'town_hall' => 'Town hall', + 'town_hall' => 'Conference', + 'space' => 'Room', ], 'room_statuses' => [ diff --git a/resources/css/app.css b/resources/css/app.css index ae92610..576908a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -45,6 +45,19 @@ html.meet-room-page { box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9); } +.meet-room-page .meet-audio-grid { + place-content: center; + place-items: center; + grid-template-columns: repeat(auto-fill, minmax(7rem, 9rem)); + gap: 1rem; +} + +.meet-room-page .meet-audio-tile [data-tile-avatar-circle] { + height: 4.5rem; + width: 4.5rem; + font-size: 1.25rem; +} + .meet-room-page .meet-speaking-bar { transform-origin: center bottom; animation: meet-speaking-pulse 0.9s ease-in-out infinite; diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 049ad39..bc7a6fc 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -38,6 +38,8 @@ function meetRoom() { needsMediaUnlock: false, waitingCount: 0, isWebinar: false, + audioOnly: false, + isSpace: false, qaItems: [], activePolls: [], breakoutBroadcast: '', @@ -105,8 +107,10 @@ function meetRoom() { this.isHost = el.dataset.isHost === '1'; this.canPublish = el.dataset.canPublish !== '0'; this.isWebinar = el.dataset.isWebinar === '1'; + this.audioOnly = el.dataset.audioOnly === '1'; + this.isSpace = el.dataset.isSpace === '1'; this.muteOnJoin = el.dataset.muteOnJoin === '1'; - this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1'; + this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly; this.isRecording = el.dataset.recordingActive === '1'; this.isLocked = el.dataset.locked === '1'; this.watermark = el.dataset.watermark === '1'; @@ -275,7 +279,7 @@ function meetRoom() { if (this.canPublish) { await this.enableLocalMedia(room); } else { - this.connectionStatus = 'View-only audience mode'; + this.connectionStatus = this.audioOnly ? 'Listening' : 'View-only audience mode'; this.isMuted = true; this.isVideoOff = true; } @@ -315,7 +319,7 @@ function meetRoom() { async enableLocalMedia(activeRoom, attempts = 3) { const wantMic = !this.muteOnJoin || this.isHost; - const wantCamera = !this.videoOffOnJoin; + const wantCamera = !this.videoOffOnJoin && !this.audioOnly; const audioOptions = { echoCancellation: true, noiseSuppression: true, @@ -651,7 +655,9 @@ function meetRoom() { if (!tile) { tile = document.createElement('div'); tile.dataset.participantTile = identity; - tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900'; + tile.className = this.audioOnly + ? 'meet-audio-tile relative flex aspect-square w-full max-w-[9rem] flex-col items-center justify-center overflow-hidden rounded-2xl bg-slate-900' + : 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900'; tile.innerHTML = `
Large-format events with assigned hosts and speakers. Optional green room for presenters before going live.
+ + +Multi-speaker events with hosts and presenters. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.
+No conferences yet.
+ @endforelse +Conference
+{{ config('meet.room_statuses')[$room->status] ?? $room->status }}
+Passcode: {{ $room->passcode }}
+ @endif + + @if ($room->scheduled_at) ++ Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} +
+ @endif + + @if ($room->description) +{{ $room->description }}
+ @endif +{{ $room->title }}
Audio room
+ @endif