Paillasse/portail/app/Http/Controllers/VisioController.php

269 lines
9.4 KiB
PHP
Raw Normal View History

<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use App\Models\Seance;
use App\Models\User;
use App\Services\Visio\JetonVisio;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* Visio de classe (étape 15). Jitsi porte le son et l'image ; le portail
* décide qui entre (jeton JWT par personne, salle imprévisible), et tient
* l'état de la classe : mains levées (premier arrivé en haut), salle
* d'attente, chat. Cet état vit dans le cache (Redis), le temps de la visio.
*/
class VisioController extends Controller
{
private const DUREE_ETAT = 6 * 3600;
// Un élève est « en salle d'attente » s'il a donné signe de vie depuis.
private const PRESENCE_SECONDES = 15;
public function __construct(private readonly JetonVisio $jetons) {}
// ---- Prof -------------------------------------------------------------
public function prof(Seance $seance): View
{
$prof = $this->verifierProf($seance);
return view('seances.visio-prof', [
'seance' => $seance->load(['classe', 'lab']),
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'prof-'.$prof->id, $prof->name, true) : null,
]);
}
public function ouvrir(Seance $seance): RedirectResponse
{
$this->verifierProf($seance);
abort_if($seance->classe->estArchivee() || $seance->statut === Seance::TERMINEE, 409);
if (! $seance->visioOuverte()) {
$seance->update([
'visio_ouverte_a' => now(),
'visio_salle' => 'syllabus-'.$seance->id.'-'.Str::lower(Str::random(16)),
'visio_chat' => false,
]);
}
return redirect()->route('seances.visio.prof', $seance);
}
public function fermer(Seance $seance): RedirectResponse
{
$this->verifierProf($seance);
$seance->update(['visio_ouverte_a' => null, 'visio_salle' => null, 'visio_chat' => false]);
Cache::forget($this->cle($seance));
return redirect()->route('seances.attente', $seance);
}
public function etatProf(Seance $seance): JsonResponse
{
$this->verifierProf($seance);
$etat = $this->etat($seance);
$limite = time() - self::PRESENCE_SECONDES;
$mains = collect($etat['mains'])->sortBy('depuis')->map(fn ($m, $id) => $m + [
'eleve_id' => $id,
'participant_id' => $etat['participants'][$id] ?? null,
])->values();
$attente = collect($etat['presences'])
->filter(fn ($p, $id) => $p['vu'] >= $limite && ! isset($etat['participants'][$id]))
->map(fn ($p) => ['nom' => $p['nom']])->values();
return response()->json([
'ouverte' => $seance->visioOuverte(),
'chat' => $seance->visio_chat,
'mains' => $mains,
'attente' => $attente,
]);
}
/**
* Le prof a donné la parole dans Jitsi (askToUnmute, qui autorise le
* micro quand la modération est active) : la main est baissée.
*/
public function parole(Seance $seance, Eleve $eleve): JsonResponse
{
$this->verifierProf($seance);
abort_if($eleve->classe_id !== $seance->classe_id, 404);
$this->modifier($seance, function (array $etat) use ($eleve) {
unset($etat['mains'][$eleve->id]);
return $etat;
});
return response()->json(['ok' => true]);
}
public function chat(Request $request, Seance $seance): JsonResponse
{
$this->verifierProf($seance);
$seance->update(['visio_chat' => $request->boolean('actif')]);
return response()->json(['chat' => $seance->visio_chat]);
}
// ---- Élève ------------------------------------------------------------
public function eleve(Seance $seance): View
{
$eleve = $this->verifierEleve($seance);
return view('seances.visio-eleve', [
'seance' => $seance->load(['classe', 'lab']),
'eleve' => $eleve,
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'eleve-'.$eleve->id, $this->nomVisible($eleve), false) : null,
]);
}
/**
* Interrogé régulièrement par la page de l'élève : sert aussi de signe de
* présence (salle d'attente côté prof).
*/
public function etatEleve(Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$etat = $this->modifier($seance, function (array $etat) use ($eleve) {
$etat['presences'][$eleve->id] = ['nom' => $this->nomVisible($eleve), 'vu' => time()];
return $etat;
});
return response()->json([
'ouverte' => $seance->visioOuverte(),
'chat' => $seance->visio_chat,
'main_levee' => isset($etat['mains'][$eleve->id]),
]);
}
public function main(Request $request, Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$levee = $request->boolean('levee');
$this->modifier($seance, function (array $etat) use ($eleve, $levee) {
if ($levee) {
$etat['mains'][$eleve->id] ??= ['nom' => $this->nomVisible($eleve), 'depuis' => time()];
} else {
unset($etat['mains'][$eleve->id]);
}
return $etat;
});
return response()->json(['main_levee' => $levee]);
}
public function participant(Request $request, Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$participant = $request->validate(['participant_id' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/']])['participant_id'];
$this->modifier($seance, function (array $etat) use ($eleve, $participant) {
$etat['participants'][$eleve->id] = $participant;
return $etat;
});
return response()->json(['ok' => true]);
}
// ---- Interne ----------------------------------------------------------
private function verifierProf(Seance $seance): User
{
$prof = Auth::guard('web')->user();
abort_if($seance->classe->user_id !== $prof->id, 403);
return $prof;
}
private function verifierEleve(Seance $seance): Eleve
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id || $seance->classe->estArchivee(), 403);
return $eleve;
}
/**
* Minimisation : visible de toute la classe, donc prénom et initiale du nom.
*/
private function nomVisible(Eleve $eleve): string
{
return $eleve->prenom.($eleve->nom ? ' '.mb_strtoupper(mb_substr($eleve->nom, 0, 1)).'.' : '');
}
/**
* Lu aussi par le tableau de bord du prof (ClasseController), d'où la
* visibilité publique de la clé et de ce compteur — jamais du contenu
* complet de l'état (noms des élèves), seulement un chiffre.
*/
public static function cleEtat(Seance $seance): string
{
return "visio:{$seance->id}";
}
public static function nombreMainsLevees(Seance $seance): int
{
return count(self::mainsLevees($seance));
}
/**
* Mains levées, premier arrivé en premier (même tri que etatProf) : nom
* déjà minimisé (prénom + initiale), rien de plus que ce qu'affiche
* déjà la page de visio.
*
* @return list<array{eleve_id: int, nom: string, depuis: int}>
*/
public static function mainsLevees(Seance $seance): array
{
/** @var array<int, array{nom: string, depuis: int}> $mains */
$mains = (Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? [];
return collect($mains)
->sortBy('depuis')
->map(fn ($m, $id) => ['eleve_id' => (int) $id, 'nom' => $m['nom'], 'depuis' => $m['depuis']])
->values()
->all();
}
private function cle(Seance $seance): string
{
return self::cleEtat($seance);
}
/**
* @return array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}
*/
private function etat(Seance $seance): array
{
return Cache::get($this->cle($seance)) ?? ['mains' => [], 'participants' => [], 'presences' => []];
}
/**
* Lecture-modification-écriture sous verrou : deux élèves qui lèvent la
* main au même instant ne s'écrasent pas.
*
* @param callable(array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}): array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>} $changement
* @return array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}
*/
private function modifier(Seance $seance, callable $changement): array
{
return Cache::lock($this->cle($seance).':verrou', 5)->block(3, function () use ($seance, $changement) {
$etat = $changement($this->etat($seance));
Cache::put($this->cle($seance), $etat, self::DUREE_ETAT);
return $etat;
});
}
}