Paillasse/portail/resources/views/seances/visio-prof.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

138 lines
12 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 prof) ; la
grille factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
@section('contenu')
<main style="background:#f8f9fa">
<section style="padding:40px 20px 8px"><div style="max-width:1280px;margin:0 auto">
<p style="margin:0;display:inline-flex;align-items:center;gap:8px;border:1px solid #b8c1cf;border-radius:999px;background:#fff;padding:4px 12px;font-size:14px;font-weight:700;color:#1d3fa8">Visio de classe</p>
<h1 style="margin:14px 0 0;font-size:clamp(30px,4vw,44px);line-height:1.1;font-weight:800;letter-spacing:-0.02em;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</h1>
<p style="margin:12px 0 0;max-width:720px;font-size:18px;line-height:1.6;font-weight:500;color:#3c4043">Tous les élèves arrivent en sourdine. Ils lèvent la main, vous donnez la parole, un à la fois.</p>
</div></section>
<section style="padding:20px 20px 72px"><div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]" style="max-width:1280px;margin:0 auto">
<div style="background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden;min-width:0">
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
@if ($jeton)
<span style="display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:999px;background:#fce8e6;color:#b3261e;font-size:13px;font-weight:800"><span style="width:8px;height:8px;border-radius:50%;background:#d93025"></span>En direct · <span id="v-chrono" style="font-family:'IBM Plex Mono',monospace">00:00:00</span></span>
@else
<span style="display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:999px;background:#eef0f3;color:#3c4043;font-size:13px;font-weight:800">Visio fermée</span>
@endif
<span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }}</span><span id="v-nb" style="font-size:14px;font-weight:600;color:#5f6368"></span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
@if ($jeton)
<button type="button" id="v-mute-all" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid #dadce0;background:#fff;color:#0b1220"><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"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18"/></svg>Couper tous les micros</button>
<form id="v-terminer" action="{{ route('seances.visio.fermer', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#d93025;color:#fff">Terminer la visio</button></form>
@else
<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#0b57d0;color:#fff">Ouvrir la visio</button></form>
@endif
</div>
</div>
@if ($jeton)
<div id="v-scene" style="height:min(70vh,620px);background:#202124"></div>
@else
<p style="margin:0;padding:40px 20px;font-size:16px;font-weight:600;color:#3c4043">La visio n’est pas ouverte. Les élèves qui la rejoignent attendent dans la salle d’attente ; ouvrez-la pour les faire entrer.</p>
@endif
</div>
<div style="display:grid;gap:20px;align-content:start;min-width:0">
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-mains"><div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-mains" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Mains levées</h2><span id="v-nb-mains" style="min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#fef7e0;color:#7a4b00;display:inline-grid;place-items:center;font-size:13px;font-weight:800">0</span></div><ol id="v-mains" style="margin:0;padding:8px 12px;list-style:none"></ol><p style="margin:0;padding:0 20px 14px;font-size:13px;font-weight:600;color:#5f6368">Premier arrivé en haut.</p></section>
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-attente"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-attente" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Salle d’attente</h2>@unless ($jeton)<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" id="v-tout-admettre" style="border:0;background:none;color:#0b57d0;font-size:14px;font-weight:700;cursor:pointer">Tout admettre</button></form>@endunless</div><ul id="v-attente" style="margin:0;padding:8px 12px;list-style:none"></ul></section>
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-chat"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-chat" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Chat des élèves</h2><button type="button" id="v-chat-switch" role="switch" aria-checked="{{ $seance->visio_chat ? 'true' : 'false' }}" aria-label="Activer le chat des élèves" @disabled(! $jeton) style="position:relative;width:44px;height:26px;border-radius:999px;border:0;cursor:pointer;transition:background .2s;background:{{ $seance->visio_chat ? '#0b57d0' : '#c4c7c5' }}"><span style="position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform .2s;transform:translateX({{ $seance->visio_chat ? '18px' : '0' }})"></span></button></div><div id="v-chat" style="padding:16px 20px;font-size:14px;font-weight:600;color:#5f6368">{{ $seance->visio_chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.' }}</div></section>
</div>
</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.etat', $seance));
var URL_CHAT = @json(route('seances.visio.chat', $seance));
var URL_PAROLE = @json(route('seances.visio.parole', [$seance, '__ELEVE__']));
var CSRF = @json(csrf_token());
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 esc = function (t) { var d = document.createElement('span'); d.textContent = t; return d.innerHTML; };
var api = null;
var BOUTONS = ['microphone', 'camera', 'desktop', 'raisehand', 'participants-pane', 'tileview', 'settings', 'fullscreen', 'hangup'];
@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'] : [])
}
});
// Seul le prof donne la parole : modération du micro dès l'arrivée.
api.addListener('videoConferenceJoined', function () {
api.executeCommand('toggleModeration', true, 'audio');
});
var compter = function () { $('#v-nb').textContent = '· ' + api.getNumberOfParticipants() + ' en ligne'; };
api.addListener('participantJoined', compter);
api.addListener('participantLeft', compter);
api.addListener('readyToClose', function () { $('#v-terminer').submit(); });
$('#v-mute-all').addEventListener('click', function () { api.executeCommand('muteEveryone', 'audio'); });
$('#v-terminer').addEventListener('submit', function () { try { api.executeCommand('endConference'); } catch (e) {} });
var debut = new Date(@json($seance->visio_ouverte_a?->toIso8601String())).getTime();
setInterval(function () {
var s = Math.max(0, Math.floor((Date.now() - debut) / 1000));
$('#v-chrono').textContent = [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
}, 1000);
$('#v-chat-switch').addEventListener('click', function () {
var actif = this.getAttribute('aria-checked') !== 'true';
poster(URL_CHAT, { actif: actif }).then(function () { rafraichir(); });
});
@endif
function rafraichir() {
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
$('#v-nb-mains').textContent = e.mains.length;
$('#v-mains').innerHTML = e.mains.length ? e.mains.map(function (m) {
var attente = Math.max(0, Math.floor(Date.now() / 1000) - m.depuis);
return '<li style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;border-radius:12px">'
+ '<span><strong>' + esc(m.nom) + '</strong> <span style="font-size:13px;color:#5f6368">' + (attente < 60 ? attente + ' s' : Math.floor(attente / 60) + ' min') + '</span></span>'
+ (api && m.participant_id ? '<button type="button" data-parole="' + m.eleve_id + '" data-participant="' + esc(m.participant_id) + '" style="display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:0;cursor:pointer;font-size:13px;font-weight:700;background:#0b57d0;color:#fff">Donner la parole</button>' : '')
+ '</li>';
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Aucune main levée.</li>';
$('#v-attente').innerHTML = e.attente.length ? e.attente.map(function (a) {
return '<li style="padding:8px;font-weight:700">' + esc(a.nom) + '</li>';
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Personne n’attend.</li>';
var sw = $('#v-chat-switch');
if (sw.getAttribute('aria-checked') !== String(e.chat)) {
sw.setAttribute('aria-checked', String(e.chat));
sw.style.background = e.chat ? '#0b57d0' : '#c4c7c5';
sw.firstElementChild.style.transform = e.chat ? 'translateX(18px)' : 'translateX(0)';
$('#v-chat').textContent = e.chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.';
if (api) api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(e.chat ? ['chat'] : []) });
}
}).catch(function () {});
}
// « Donner la parole » : askToUnmute autorise le micro quand la modération
// est active (documentation de l'IFrame API), puis la main est baissée.
$('#v-mains').addEventListener('click', function (ev) {
var b = ev.target.closest('[data-parole]');
if (!b || !api) return;
api.executeCommand('askToUnmute', b.dataset.participant);
poster(URL_PAROLE.replace('__ELEVE__', b.dataset.parole)).then(rafraichir);
});
rafraichir();
setInterval(rafraichir, 2000);
})();
</script>
@endsection