Deploy Ladill Meet / deploy (push) Successful in 34s
Stop registerPresenter from demoting hosts to panelists, recognize room owners as managers, and show stage controls in the green room banner. Co-authored-by: Cursor <cursoragent@cursor.com>
373 lines
14 KiB
PHP
373 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Meet;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\BreakoutRoom;
|
|
use App\Models\MeetPoll;
|
|
use App\Models\Participant;
|
|
use App\Models\QaQuestion;
|
|
use App\Models\Session;
|
|
use App\Models\SessionFile;
|
|
use App\Models\Whiteboard;
|
|
use App\Services\Meet\BreakoutService;
|
|
use App\Services\Meet\FileSharingService;
|
|
use App\Services\Meet\LiveStreamService;
|
|
use App\Services\Meet\PollService;
|
|
use App\Services\Meet\QaService;
|
|
use App\Services\Meet\StageLayoutService;
|
|
use App\Services\Meet\TownHallService;
|
|
use App\Services\Meet\WhiteboardService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class MeetingFeaturesController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected QaService $qa,
|
|
protected PollService $polls,
|
|
protected BreakoutService $breakouts,
|
|
protected FileSharingService $files,
|
|
protected WhiteboardService $whiteboards,
|
|
protected LiveStreamService $liveStreams,
|
|
protected TownHallService $townHall,
|
|
protected StageLayoutService $stageLayout,
|
|
) {}
|
|
|
|
// --- Q&A ---
|
|
|
|
public function submitQuestion(Request $request, Session $session): JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
$validated = $request->validate(['question' => ['required', 'string', 'max:1000']]);
|
|
$question = $this->qa->submit($session, $participant, $validated['question']);
|
|
|
|
return response()->json(['question' => $question]);
|
|
}
|
|
|
|
public function approveQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
abort_unless($question->session_id === $session->id, 404);
|
|
|
|
return response()->json(['question' => $this->qa->approve($question)]);
|
|
}
|
|
|
|
public function answerQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
|
{
|
|
$participant = $this->hostOnly($request, $session);
|
|
abort_unless($question->session_id === $session->id, 404);
|
|
$validated = $request->validate(['answer' => ['required', 'string', 'max:2000']]);
|
|
|
|
return response()->json([
|
|
'question' => $this->qa->answer($question, $validated['answer'], $participant->display_name),
|
|
]);
|
|
}
|
|
|
|
public function upvoteQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
|
{
|
|
$this->participant($request, $session);
|
|
abort_unless($question->session_id === $session->id, 404);
|
|
|
|
return response()->json(['question' => $this->qa->upvote($question)]);
|
|
}
|
|
|
|
// --- Polls ---
|
|
|
|
public function createPoll(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$validated = $request->validate([
|
|
'question' => ['required', 'string', 'max:500'],
|
|
'options' => ['required', 'array', 'min:2', 'max:6'],
|
|
'options.*' => ['required', 'string', 'max:200'],
|
|
]);
|
|
|
|
$poll = $this->polls->create($session, $validated['question'], $validated['options']);
|
|
|
|
return response()->json(['poll' => $poll], 201);
|
|
}
|
|
|
|
public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
abort_unless($poll->session_id === $session->id, 404);
|
|
$validated = $request->validate(['option_index' => ['required', 'integer', 'min:0']]);
|
|
$this->polls->vote($poll, $participant, $validated['option_index']);
|
|
|
|
return response()->json($this->polls->results($poll));
|
|
}
|
|
|
|
public function closePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
abort_unless($poll->session_id === $session->id, 404);
|
|
|
|
return response()->json($this->polls->results($this->polls->close($poll)));
|
|
}
|
|
|
|
// --- Breakouts ---
|
|
|
|
public function createBreakouts(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$validated = $request->validate([
|
|
'count' => ['required', 'integer', 'min:1', 'max:20'],
|
|
'timer_minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
|
|
]);
|
|
|
|
$rooms = $this->breakouts->createRooms(
|
|
$session,
|
|
$validated['count'],
|
|
$validated['timer_minutes'] ?? null,
|
|
);
|
|
|
|
return response()->json(['breakouts' => $rooms]);
|
|
}
|
|
|
|
public function moveToBreakout(Request $request, Session $session, BreakoutRoom $breakout): JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
abort_unless($breakout->session_id === $session->id, 404);
|
|
$this->breakouts->moveParticipant($participant, $breakout);
|
|
|
|
return response()->json(['breakout_uuid' => $breakout->uuid, 'media_room' => $breakout->media_room_name]);
|
|
}
|
|
|
|
public function returnFromBreakout(Request $request, Session $session): JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
$this->breakouts->returnToMain($participant);
|
|
|
|
return response()->json(['returned' => true]);
|
|
}
|
|
|
|
public function broadcastBreakout(Request $request, Session $session): JsonResponse
|
|
{
|
|
$participant = $this->hostOnly($request, $session);
|
|
$validated = $request->validate(['message' => ['required', 'string', 'max:1000']]);
|
|
$message = $this->breakouts->broadcast($session, $validated['message'], $participant->display_name);
|
|
|
|
return response()->json(['message' => $message]);
|
|
}
|
|
|
|
public function closeBreakouts(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$this->breakouts->closeAll($session);
|
|
|
|
return response()->json(['closed' => true]);
|
|
}
|
|
|
|
// --- Files ---
|
|
|
|
public function uploadFile(Request $request, Session $session): JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
$validated = $request->validate(['file' => ['required', 'file', 'max:51200']]);
|
|
|
|
$file = $this->files->upload(
|
|
$session->room,
|
|
$session,
|
|
$validated['file'],
|
|
$participant->user_ref ?? $participant->uuid,
|
|
$participant->display_name,
|
|
);
|
|
|
|
return response()->json(['file' => $file], 201);
|
|
}
|
|
|
|
public function downloadFile(Request $request, Session $session, SessionFile $file): StreamedResponse|JsonResponse
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
abort_unless($file->room_id === $session->room_id, 404);
|
|
|
|
if ($file->drive_file_id) {
|
|
return response()->json(['url' => $this->files->download($file, $participant)]);
|
|
}
|
|
|
|
$path = $this->files->download($file, $participant);
|
|
|
|
return Storage::disk(config('meet.files.disk', 'local'))->download($file->storage_path, $file->original_name);
|
|
}
|
|
|
|
// --- Whiteboard ---
|
|
|
|
public function showWhiteboard(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->participant($request, $session);
|
|
$board = $this->whiteboards->ensureForSession($session);
|
|
|
|
return response()->json(['whiteboard' => $board]);
|
|
}
|
|
|
|
public function saveWhiteboard(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->participant($request, $session);
|
|
$validated = $request->validate(['state' => ['required', 'array']]);
|
|
$board = $this->whiteboards->ensureForSession($session);
|
|
|
|
return response()->json(['whiteboard' => $this->whiteboards->saveState($board, $validated['state'])]);
|
|
}
|
|
|
|
public function exportWhiteboard(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->participant($request, $session);
|
|
$validated = $request->validate(['image_data' => ['required', 'string']]);
|
|
$data = preg_replace('#^data:image/\w+;base64,#i', '', $validated['image_data']);
|
|
$binary = base64_decode($data, true);
|
|
abort_unless($binary !== false, 422);
|
|
|
|
$path = 'whiteboards/'.$session->uuid.'/'.now()->timestamp.'.png';
|
|
Storage::disk(config('meet.files.disk', 'local'))->put($path, $binary);
|
|
|
|
return response()->json(['export_path' => $path]);
|
|
}
|
|
|
|
// --- Live streaming ---
|
|
|
|
public function configureLiveStream(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$validated = $request->validate([
|
|
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
|
'rtmp_url' => ['nullable', 'url', 'max:2048'],
|
|
'stream_key' => ['required', 'string', 'max:512'],
|
|
'layout' => ['nullable', 'string', 'in:'.implode(',', config('meet.live_stream.layouts'))],
|
|
]);
|
|
|
|
$stream = $this->liveStreams->configure($session, $request->user(), $validated);
|
|
|
|
return response()->json(['stream' => $stream->makeHidden('stream_key')], 201);
|
|
}
|
|
|
|
public function startLiveStream(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$validated = $request->validate([
|
|
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
|
]);
|
|
|
|
$stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail();
|
|
$started = $this->liveStreams->start($stream);
|
|
|
|
return response()->json(['stream' => $started->makeHidden('stream_key')]);
|
|
}
|
|
|
|
public function stopLiveStream(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$validated = $request->validate([
|
|
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
|
]);
|
|
|
|
$stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail();
|
|
|
|
return response()->json(['stream' => $this->liveStreams->stop($stream)->makeHidden('stream_key')]);
|
|
}
|
|
|
|
// --- Town hall ---
|
|
|
|
public function startGreenRoom(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
abort_unless($session->room->isTownHall(), 422);
|
|
|
|
$greenRoom = $this->townHall->startGreenRoom($session->room, $request->user());
|
|
|
|
return response()->json(['session_uuid' => $greenRoom->uuid, 'mode' => 'green_room']);
|
|
}
|
|
|
|
public function goLiveTownHall(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$live = $this->townHall->goLive($session, $request->user());
|
|
$participant = app(SessionService::class)->joinedParticipantForUser($live, $request->user());
|
|
|
|
if ($participant) {
|
|
app(SessionService::class)->rememberParticipantSession($request, $participant, $live);
|
|
}
|
|
|
|
return response()->json([
|
|
'session_uuid' => $live->uuid,
|
|
'mode' => 'live',
|
|
'room_url' => route('meet.room', $live),
|
|
]);
|
|
}
|
|
|
|
public function addPresenter(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
abort_unless($session->room->isTownHall(), 422);
|
|
|
|
$validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]);
|
|
|
|
$updated = $this->townHall->addPresenter($session, $validated['user_ref']);
|
|
|
|
return response()->json([
|
|
'session' => $updated,
|
|
'presenter_refs' => $updated->presenter_refs,
|
|
]);
|
|
}
|
|
|
|
public function handoffCoHost(Request $request, Session $session): JsonResponse
|
|
{
|
|
$participant = $this->hostOnly($request, $session);
|
|
$validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]);
|
|
|
|
return response()->json([
|
|
'participant' => $this->townHall->handoffCoHost($session, $participant, $validated['user_ref']),
|
|
]);
|
|
}
|
|
|
|
public function updateStage(Request $request, Session $session): JsonResponse
|
|
{
|
|
$this->hostOnly($request, $session);
|
|
$room = $session->room;
|
|
abort_unless($this->stageLayout->usesStageLayout($room), 422);
|
|
|
|
$validated = $request->validate([
|
|
'panel_discussions' => ['sometimes', 'boolean'],
|
|
'stage_layout' => ['sometimes', 'string', 'in:plenary,panel'],
|
|
'active_panel_id' => ['nullable', 'string', 'max:64'],
|
|
'spotlight_speaker_ref' => ['nullable', 'string', 'max:64'],
|
|
'panels' => ['sometimes', 'array', 'max:12'],
|
|
'panels.*.id' => ['nullable', 'string', 'max:64'],
|
|
'panels.*.name' => ['required_with:panels', 'string', 'max:100'],
|
|
'panels.*.speaker_refs' => ['nullable', 'array'],
|
|
'panels.*.speaker_refs.*' => ['string', 'max:64'],
|
|
]);
|
|
|
|
$room = $this->stageLayout->update($room, $validated);
|
|
|
|
return response()->json($this->stageLayout->config($room));
|
|
}
|
|
|
|
protected function participant(Request $request, Session $session): Participant
|
|
{
|
|
$uuid = $request->session()->get("meet.participant.{$session->uuid}");
|
|
abort_unless($uuid, 403);
|
|
|
|
return Participant::where('uuid', $uuid)->where('session_id', $session->id)->firstOrFail();
|
|
}
|
|
|
|
protected function hostOnly(Request $request, Session $session): Participant
|
|
{
|
|
$participant = $this->participant($request, $session);
|
|
$user = $request->user();
|
|
$isRoomHost = $user && $user->ownerRef() === $session->room->host_user_ref;
|
|
|
|
abort_unless($participant->isHost() || $isRoomHost, 403);
|
|
|
|
if ($isRoomHost && ! $participant->isHost()) {
|
|
$participant->update(['role' => 'host']);
|
|
|
|
return $participant->fresh();
|
|
}
|
|
|
|
return $participant;
|
|
}
|
|
}
|