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>
This commit is contained in:
parent
dd4f9c3c55
commit
ec4eae3744
36 changed files with 1406 additions and 18 deletions
|
|
@ -48,6 +48,7 @@
|
|||
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
||||
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
||||
<li>s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.</li>
|
||||
<li>pendant une visio de classe : le nom affiché aux autres participants (prénom et initiale du nom, non modifiable) et les mains levées. Le son et l’image transitent par notre serveur, en France, <strong>sans être enregistrés</strong>.</li>
|
||||
</ul>
|
||||
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
||||
<h3>Enseignants</h3>
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
||||
<li><strong>Demande pour rejoindre une classe</strong> : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.</li>
|
||||
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
||||
<li><strong>Visio</strong> : rien n’est enregistré ; l’état de la séance (mains levées, présence) reste en mémoire au plus 6 heures.</li>
|
||||
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
||||
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
||||
</ul>
|
||||
|
|
@ -112,7 +114,7 @@
|
|||
<h2 id="t-cookies">Cookies</h2>
|
||||
<div class="mt-6 grid gap-4 text-lg font-medium leading-relaxed">
|
||||
<p>Syllabus n’utilise que deux cookies, strictement nécessaires : <span class="font-mono">syllabus-session</span>, qui vous garde connecté, et <span class="font-mono">XSRF-TOKEN</span>, qui protège les formulaires. Aucun cookie publicitaire ni de mesure d’audience. Ils ne demandent donc pas de consentement.</p>
|
||||
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et une préférence d’affichage. Ces informations ne nous sont pas transmises.</p>
|
||||
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et des préférences d’affichage (y compris celles du lecteur de visio, sur <span class="font-mono">meet.syllabuslab.fr</span>). Ces informations ne nous sont pas transmises.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
@csrf
|
||||
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Rejoindre la séance</button>
|
||||
</form>
|
||||
@if ($seance->visioOuverte())
|
||||
<a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio</a>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
@section('contenu')
|
||||
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
|
||||
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
|
||||
@if ($seance->visioOuverte())
|
||||
<p class="mt-3"><a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio de la classe</a></p>
|
||||
@endif
|
||||
@if ($labSession->etat === $etats::EN_ATTENTE)
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<p class="font-mono text-sm font-semibold">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</p>
|
||||
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente</h1>
|
||||
<p class="mt-2 font-semibold">La page se rafraîchit toutes les 10 secondes.</p>
|
||||
<p class="mt-4"><a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}</a></p>
|
||||
|
||||
@if ($seance->labSessions->isEmpty())
|
||||
<p class="mt-8 font-semibold">Personne n'a encore rejoint la séance.</p>
|
||||
|
|
|
|||
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
@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
|
||||
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
@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
|
||||
Loading…
Add table
Add a link
Reference in a new issue