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>
100 lines
7.5 KiB
PHP
100 lines
7.5 KiB
PHP
@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
|