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>
138 lines
12 KiB
PHP
138 lines
12 KiB
PHP
@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
|