Paillasse/portail/resources/views/seances/visio-eleve.blade.php
Adam Belkacemi ec4eae3744 ajoute la visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur
Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr,
accès par jeton JWT signé par le portail, modération du micro, mains levées
synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne
br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait
le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:35:35 +02:00

100 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.vitrine')
@section('titre', 'Visio · '.$seance->classe->nom)
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue élève) ;
l'image factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
@section('contenu')
<main style="background:#f8f9fa">
<section style="padding:20px 20px 72px"><div style="max-width:1100px;margin:0 auto;background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden">
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</span><span data-statut-e style="display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:700;background:#eef0f3;color:#3c4043">{{ $jeton ? 'En sourdine' : 'En attente' }}</span></div>
@if ($jeton)
<div style="padding:20px"><div id="v-scene" style="position:relative;aspect-ratio:16/9;border-radius:16px;overflow:hidden;background:#202124"></div></div>
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:12px;padding:4px 20px 24px">
<button type="button" data-micro-e aria-label="Micro" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/></svg></button>
<button type="button" data-cam-e aria-label="Caméra" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg></button>
<button type="button" data-main-e aria-pressed="false" class="border border-[#e3e5e8] rounded-2xl bg-[#fce8e6] px-5 py-4 text-left text-lg font-extrabold aria-pressed:bg-[#fef7e0]" style="display:inline-flex;align-items:center;gap:10px;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg><span>Lever la main</span></button>
<a href="{{ route('seances.eleve.attente', $seance) }}" data-quitter style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;cursor:pointer;border-radius:999px;height:52px;padding:0 22px;background:#d93025;color:#fff;text-decoration:none"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Quitter</a>
</div>
<p data-aide-e style="margin:0;padding:0 20px 24px;text-align:center;font-size:14px;font-weight:600;color:#5f6368">Tu es en sourdine. Lève la main pour demander la parole.</p>
@else
<p style="margin:0;padding:48px 20px;text-align:center;font-size:18px;font-weight:700;color:#3c4043">Ton prof n’a pas encore ouvert la visio. Garde cette page ouverte : tu entreras dès qu’il l’ouvrira.</p>
@endif
</div></section>
</main>
@if ($jeton)
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
@endif
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var URL_ETAT = @json(route('seances.visio.eleve.etat', $seance));
var URL_MAIN = @json(route('seances.visio.eleve.main', $seance));
var URL_PARTICIPANT = @json(route('seances.visio.eleve.participant', $seance));
var CSRF = @json(csrf_token());
var OUVERTE = @json((bool) $jeton);
var $ = function (s) { return document.querySelector(s); };
var poster = function (url, donnees) {
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
};
var api = null, moi = null, mainLevee = false, chat = null;
var BOUTONS = ['microphone', 'camera', 'raisehand', 'tileview', 'fullscreen', 'hangup'];
function statut(texte, couleur, fond, aide) {
var s = $('[data-statut-e]'); s.textContent = texte; s.style.color = couleur; s.style.background = fond;
var a = $('[data-aide-e]'); if (a && aide) a.textContent = aide;
}
@if ($jeton)
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
roomName: @json($seance->visio_salle),
jwt: @json($jeton),
parentNode: $('#v-scene'),
width: '100%', height: '100%',
configOverwrite: {
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
prejoinConfig: { enabled: false }, disableDeepLinking: true,
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
}
});
api.addListener('videoConferenceJoined', function (e) {
moi = e.id;
poster(URL_PARTICIPANT, { participant_id: e.id });
});
// La main levée passe toujours par Jitsi (bouton de la page ou de Jitsi) ;
// l'application suit l'événement : une seule source, pas de désaccord.
api.addListener('raiseHandUpdated', function (e) {
if (e.id !== moi) return;
mainLevee = !!e.handRaised;
$('[data-main-e]').setAttribute('aria-pressed', String(mainLevee));
$('[data-main-e] span').textContent = mainLevee ? 'Baisser la main' : 'Lever la main';
poster(URL_MAIN, { levee: mainLevee });
});
api.addListener('audioMuteStatusChanged', function (e) {
if (e.muted) statut('En sourdine', '#3c4043', '#eef0f3', 'Tu es en sourdine. Lève la main pour demander la parole.');
else statut('Tu as la parole', '#0d652d', '#e6f4ea', 'Ton prof t’a donné la parole. Coupe ton micro quand tu as fini.');
});
api.addListener('readyToClose', function () { location.href = $('[data-quitter]').href; });
$('[data-main-e]').addEventListener('click', function () { api.executeCommand('toggleRaiseHand'); });
$('[data-micro-e]').addEventListener('click', function () { api.executeCommand('toggleAudio'); });
$('[data-cam-e]').addEventListener('click', function () { api.executeCommand('toggleVideo'); });
$('[data-quitter]').addEventListener('click', function () { try { api.executeCommand('hangup'); } catch (e) {} });
@endif
function rafraichir() {
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
if (e.ouverte !== OUVERTE) { location.reload(); return; }
if (!api) return;
// Le prof a donné la parole : l'application a baissé la main, Jitsi suit.
if (mainLevee && !e.main_levee) api.executeCommand('toggleRaiseHand');
if (chat !== e.chat) {
chat = e.chat;
api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(chat ? ['chat'] : []) });
}
}).catch(function () {});
}
rafraichir();
setInterval(rafraichir, 2000);
})();
</script>
@endsection