Commit ae0f9124 authored by Kourser's avatar Kourser
Browse files

Celui qui partageait son écran était le seul à ne pas le voir

Ses propres pistes vivaient hors du tableau : la caméra dans un état séparé,
l'écran nulle part. La boucle des tuiles ne parcourait que les participants
distants. Celui qui partageait voyait son bouton passer à « Écran partagé » et
rien d'autre changer, pendant que les autres recevaient bien l'image — un
retour visuel qui n'existe que chez les autres n'est pas un retour, et on
concluait que le partage ne fonctionnait pas.

Tout ce qui s'affiche est maintenant une tuile, la sienne comprise. Et une
tuile se met en avant : elle prend la largeur, les autres se resserrent en
bande. Un écran partagé passe devant de lui-même — partager son écran, c'est
demander qu'on le regarde — sans jamais chasser la tuile qu'on a choisie soi.

La règle vit hors du composant parce qu'elle a des règles et que des règles se
vérifient : on lui donne des salles, on regarde ce qu'elle met en avant. Un
contrôle textuel aurait dit « la fonction est appelée » sans jamais dire
qu'elle décide juste. Quatre cas éprouvés, dont celui du partage qui commence
après un refus : refuser une fois ne doit pas rendre l'automatisme muet pour
tout l'appel.

La scène n'impose aucune forme à ce qu'on partage. Figée en 16/9, elle bordait
de noir tout écran qui ne l'était pas — et un écran de bureau ne l'est presque
jamais. Mesuré dans un panneau de 1400 × 900 : 992 × 558 et 129 px de vide
sous la bande ; sans la forme imposée, 1366 × 687 et plus rien de perdu.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7d3d9608
Loading
Loading
Loading
Loading
Loading
+118 −0
Changes for apps/api/src/verification/interface.ts: 118 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -331,6 +331,7 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  // de l'interface, et l'inclure dans celui-ci le ferait compiler sans la
  // bibliothèque du navigateur.
  await verifierGardeEcriture(r);
  await verifierMiseEnAvant(r);

  // ── Le serveur formule aussi (EX-21) ─────────────────────────────────────
  //
@@ -778,3 +779,120 @@ async function verifierGardeEcriture(r: Rapport): Promise<void> {
    globalThis.fetch = vrai;
  }
}

/**
 * Ce qui passe devant dans un appel, éprouvé plutôt que lu (EF-803).
 *
 * La règle est ailleurs que dans le composant, précisément pour qu'on puisse
 * la faire tourner : on lui donne des salles, on regarde ce qu'elle met en
 * avant. Un contrôle textuel aurait dit « la fonction est appelée » sans
 * jamais dire qu'elle décide juste.
 *
 * Le défaut d'origine : la grille traitait la carte partagée comme un visage
 * de plus, cent soixante pixels de large, illisible — et rien ne permettait de
 * lui donner la place sans agrandir le panneau entier.
 */
async function verifierMiseEnAvant(r: Rapport): Promise<void> {
  r.titre("Ce qui passe devant dans un appel");

  const chemin: string = new URL("../../../web/src/coque/miseEnAvant.js",
    import.meta.url).href;
  const { miseEnAvant } = await import(chemin) as {
    miseEnAvant: (vues: readonly { cle: string; ecran: boolean }[],
      choix: { cle: string | null; ecrans: readonly string[] } | null)
      => { avant: string | null; choix: { cle: string | null; ecrans: readonly string[] } | null };
  };

  const visage = (cle: string) => ({ cle, ecran: false });
  const partage = (cle: string) => ({ cle, ecran: true });

  // ── Personne ne partage : la grille, et aucun visage désigné ─────────────
  const cellule = [visage("local:camera"), visage("a:camera"), visage("b:camera")];
  r.verifie("EF-803", "sans partage, aucune tuile n'est mise en avant d'office",
    miseEnAvant(cellule, null).avant === null,
    String(miseEnAvant(cellule, null).avant));

  // ── Un écran partagé passe devant sans qu'on le demande ──────────────────
  const avecEcran = [...cellule, partage("a:ecran")];
  r.verifie("EF-803", "un écran partagé passe devant de lui-même",
    miseEnAvant(avecEcran, null).avant === "a:ecran",
    String(miseEnAvant(avecEcran, null).avant));

  // ── Un choix explicite tient contre l'automatisme ────────────────────────
  //
  // Mettre en avant le visage de la direction pendant qu'elle parle, puis voir
  // un partage démarrer ailleurs : la place ne doit pas changer de main.
  const choisi = { cle: "b:camera", ecrans: [] as readonly string[] };
  r.verifie("EF-803", "un partage qui démarre ne chasse pas la tuile qu'on a choisie",
    miseEnAvant(avecEcran, choisi).avant === "b:camera",
    String(miseEnAvant(avecEcran, choisi).avant));

  // ── La tuile choisie s'en va : la place est rendue ───────────────────────
  //
  // Sans cette règle, le premier plan resterait occupé par un carré vide —
  // pire que la grille, puisqu'il masque les autres.
  const partie = miseEnAvant([visage("local:camera"), visage("a:camera")], choisi);
  r.verifie("EF-803", "la tuile choisie qui disparaît rend la place plutôt qu'un carré vide",
    partie.avant === null, String(partie.avant));
  r.verifie("EF-803", "et le choix tombe avec elle, sinon l'automatisme resterait muet",
    partie.choix === null, JSON.stringify(partie.choix));

  // ── On redemande la grille : elle est rendue, et elle le reste ───────────
  const refus = { cle: null, ecrans: ["a:ecran"] as readonly string[] };
  r.verifie("EF-803", "redemander la grille la rend, malgré le partage en cours",
    miseEnAvant(avecEcran, refus).avant === null,
    String(miseEnAvant(avecEcran, refus).avant));

  // ── Mais un partage qu'on n'a pas refusé passe devant ────────────────────
  //
  // Refuser une fois ne doit pas rendre l'automatisme muet pour tout l'appel :
  // la carte que la direction partagera vingt minutes plus tard n'a jamais été
  // refusée, elle.
  const plusTard = miseEnAvant([...avecEcran, partage("b:ecran")], refus);
  r.verifie("EF-803", "un partage commencé après le refus passe devant quand même",
    plusTard.avant === "b:ecran", String(plusTard.avant));
  r.verifie("EF-803", "et c'est bien le nouveau, pas celui qu'on avait écarté",
    plusTard.avant !== "a:ecran", String(plusTard.avant));

  // ── Sa propre tuile est une tuile comme les autres ───────────────────────
  //
  // Elle vivait à part — un état séparé pour sa caméra, rien pour son écran.
  // C'est ce qui faisait croire que le partage d'écran ne fonctionnait pas :
  // celui qui partageait ne voyait rien changer chez lui.
  const sien = await readFile(join(web, "coque/PanneauVisio.tsx"), "utf8");
  r.verifie("EF-803", "sa propre caméra et son propre écran sont des tuiles du même tableau",
    /cle: "local:camera"/.test(sien) && /cle: "local:ecran"/.test(sien)
      && !/pisteLocale/.test(sien));
  r.verifie("EF-803", "et la mise en avant se joue par une classe, non par un déplacement",
    /className=\{`tuiles\$\{enAvant \? " avant" : ""\}`\}/.test(sien));

  // ── Le geste existe, et il se dit ────────────────────────────────────────
  //
  // Un bouton sans nom accessible est un bouton qu'on ne peut ni annoncer ni
  // atteindre au clavier ; ici il ne porte qu'une flèche.
  r.verifie("EF-803", "le geste porte un nom accessible et son état",
    /aria-label=\{tuile\.cle === enAvant/.test(sien)
      && /aria-pressed=\{tuile\.cle === enAvant\}/.test(sien));

  const styles = await readFile(join(web, "styles.css"), "utf8");
  r.verifie("EF-803", "la tuile en avant prend toute la largeur, les autres se resserrent",
    /\.tuiles\.avant \.tuile\.grande \{[^}]*grid-column: 1 \/ -1/.test(styles));
  // ── Et elle épouse la forme de ce qu'on partage (EF-803) ─────────────────
  //
  // Elle a été figée en 16/9, et un écran de bureau ne l'est presque jamais :
  // un 16/10, un 3/2, un écran pivoté, une fenêtre. Mesuré dans un panneau de
  // 1400 × 900 : la scène tombait à 992 × 558 et laissait 129 px de vide sous
  // la bande de vignettes. Sans la forme imposée, elle occupe 1366 × 687 et ne
  // laisse plus rien — soixante-dix pour cent d'image en plus, pour le même
  // panneau.
  r.verifie("EF-803", "la scène n'impose aucune forme à ce qu'on partage",
    /\.tuiles\.avant \.tuile\.grande \{[^}]*aspect-ratio: auto/.test(styles));
  r.verifie("EF-803", "et agrandie, elle prend toute la hauteur que la bande laisse",
    /\.visio\.agrandi \.tuiles\.avant \{[^}]*grid-template-rows: minmax\([^}]*align-content: stretch/
      .test(styles));
  // Au doigt il n'y a pas de survol : un bouton qui n'apparaît qu'au survol
  // n'existe pas sur une tablette, et c'est sur tablette qu'on tient une main
  // courante en réunion.
  r.verifie("EF-803", "et le geste reste atteignable là où il n'y a pas de survol",
    /@media \(hover: none\) \{\s*\.visio \.tuile \.avancer \{ opacity: 1; \}/.test(styles));
}
+126 −37
Changes for apps/web/src/coque/PanneauVisio.tsx: 126 added lines, 37 removed lines.
Original line number Diff line number Diff line
import { useCallback, useEffect, useRef, useState } from "react";
import {
  Room, RoomEvent, Track,
  type LocalTrack, type RemoteTrack, type RemoteParticipant,
  type RemoteParticipant,
} from "livekit-client";
import { api, ErreurApi } from "../api.js";
import { initiales } from "../etat.js";
import { useCapacites } from "../capacites.js";
import { useT } from "../langues/index.js";
import { miseEnAvant, type Choix } from "./miseEnAvant.js";

/**
 * ╔══════════════════════════════════════════════════════════════════════════╗
@@ -31,9 +32,14 @@ export interface Appel {
interface Tuile {
  /** Distincte par participant ET par source : un écran partagé est une tuile. */
  cle: string;
  /** Ce dont on tire les initiales quand aucune image n’arrive. */
  nom: string;
  piste: RemoteTrack | null;
  /** Ce qui s’inscrit sur la tuile — « Vous » pour soi, le nom pour les autres. */
  libelle: string;
  piste: Track | null;
  ecran: boolean;
  /** Une de ses propres pistes : muette, et retournée si c’est sa caméra. */
  soi: boolean;
}

type Taille = "reduit" | "flottant" | "agrandi";
@@ -49,15 +55,29 @@ function PanneauVisio({
  const [erreur, definirErreur] = useState<string | null>(null);
  const [tuiles, definirTuiles] = useState<Tuile[]>([]);
  /**
   * Sa propre image (EF-803).
   * La tuile mise en avant, s'il y en a une (EF-803).
   *
   * Une grille à parts égales convient à quatre visages ; elle ne convient pas
   * à une carte, à un tableau de bord ou à une main courante qu'on partage. Le
   * document se retrouvait dans un carré de cent soixante pixels de large, où
   * il n'était pas lisible — et l'appel n'offrait aucun moyen de lui donner la
   * place, sinon agrandir le panneau entier, ce qui agrandit tout le reste
   * avec lui.
   *
   * On garde la clé plutôt qu'un rang : les tuiles se renumérotent à chaque
   * entrée et à chaque sortie, et la mise en avant sauterait de personne en
   * personne au gré des arrivées.
   */
  const [enAvant, definirEnAvant] = useState<string | null>(null);
  /**
   * Ce que quelqu'un a choisi lui-même, pour que l'automatisme se taise.
   *
   * Elle manquait, et le défaut ne se voyait pas dans le code : la tuile locale
   * n'affichait que des initiales, si bien qu'allumer sa caméra publiait bien la
   * piste — les autres voyaient — sans qu'on se voie soi-même. On ne savait donc
   * pas si l'on était cadré, éclairé, ni même si la caméra fonctionnait, ce qui
   * est la première chose qu'on vérifie avant de parler à sa direction.
   * Un écran partagé passe devant tout seul — c'est ce que veut celui qui le
   * partage. Mais si l'on a mis en avant le visage de la direction pendant
   * qu'elle parle, un partage qui démarre ailleurs ne doit pas le chasser.
   * Les règles vivent dans « miseEnAvant » : elles s'y éprouvent.
   */
  const [pisteLocale, definirPisteLocale] = useState<LocalTrack | null>(null);
  const choix = useRef<Choix | null>(null);
  const [micro, definirMicro] = useState(true);
  /** Ce que l'appel a perdu sans échouer : le micro, le plus souvent. */
  const [geneMicro, definirGeneMicro] = useState<string | null>(null);
@@ -86,24 +106,50 @@ function PanneauVisio({
  useEffect(() => {
    let vivant = true;

    /**
     * Une seule liste, la sienne comprise.
     *
     * Ses propres pistes vivaient à part, hors du tableau : la caméra dans un
     * état séparé, l'écran nulle part. C'est ce qui a fait croire que le
     * partage d'écran ne fonctionnait pas — celui qui partageait voyait son
     * bouton changer et rien d'autre, pendant que les autres recevaient bien
     * l'image. Un retour visuel qui n'existe que chez les autres n'est pas un
     * retour. Tout ce qui s'affiche est désormais une tuile, et une tuile se
     * met en avant, quelle qu'en soit la provenance.
     */
    const rafraichir = () => {
      const liste: Tuile[] = [];
      const moi = salle.localParticipant;
      // Soi d'abord, toujours à la même place : on cherche son propre cadrage
      // au même endroit à chaque coup d'œil.
      const liste: Tuile[] = [{
        cle: "local:camera", nom: moi.name ?? "Moi", libelle: t("Vous"),
        piste: moi.getTrackPublication(Track.Source.Camera)?.track ?? null,
        ecran: false, soi: true,
      }];
      const sien = moi.getTrackPublication(Track.Source.ScreenShare)?.track;
      if (sien) {
        liste.push({
          cle: "local:ecran", nom: moi.name ?? "Moi", libelle: t("Votre écran"),
          piste: sien, ecran: true, soi: true,
        });
      }
      for (const p of salle.remoteParticipants.values() as Iterable<RemoteParticipant>) {
        const nom = p.name || p.identity;
        liste.push({
          cle: `${p.identity}:camera`, nom, ecran: false,
          cle: `${p.identity}:camera`, nom, libelle: nom, ecran: false, soi: false,
          piste: p.getTrackPublication(Track.Source.Camera)?.track ?? null,
        });
        // Un écran partagé ne remplace pas le visage de qui le partage : on
        // veut voir le document ET la personne qui le commente.
        const ecran = p.getTrackPublication(Track.Source.ScreenShare)?.track;
        if (ecran) {
          liste.push({ cle: `${p.identity}:ecran`, nom: `${nom} · écran`, piste: ecran, ecran: true });
          liste.push({
            cle: `${p.identity}:ecran`, nom, libelle: `${nom} · ${t("écran")}`,
            piste: ecran, ecran: true, soi: false,
          });
        }
      }
      definirTuiles(liste);
      definirPisteLocale(
        salle.localParticipant.getTrackPublication(Track.Source.Camera)?.track ?? null);
    };

    salle.on(RoomEvent.ParticipantConnected, rafraichir)
@@ -228,10 +274,31 @@ function PanneauVisio({
      const cible = conteneurs.current.get(tuile.cle);
      if (cible && tuile.piste) tuile.piste.attach(cible.querySelector("video")!);
    }
    const moi = conteneurs.current.get("local:camera");
    const video = moi?.querySelector("video");
    if (video && pisteLocale) pisteLocale.attach(video);
  }, [tuiles, pisteLocale]);
  }, [tuiles]);

  // La liste des tuiles change à chaque entrée, chaque sortie, chaque partage :
  // on redemande alors ce qui doit être devant, choix manuel compris.
  useEffect(() => {
    const decision = miseEnAvant(tuiles, choix.current);
    choix.current = decision.choix;
    definirEnAvant((actuel) => (actuel === decision.avant ? actuel : decision.avant));
  }, [tuiles]);

  /**
   * Mettre devant, ou rendre à la grille — et dans les deux cas, c'est un choix.
   *
   * On retient les écrans partagés à cet instant : un partage qui commencera
   * plus tard passera devant malgré le refus, parce qu'on ne l'a pas refusé,
   * lui.
   */
  const mettreEnAvant = useCallback((cle: string) => {
    const suivant = enAvant === cle ? null : cle;
    choix.current = {
      cle: suivant,
      ecrans: tuiles.filter((x) => x.ecran).map((x) => x.cle),
    };
    definirEnAvant(suivant);
  }, [enAvant, tuiles]);

  async function quitter() {
    await salle.disconnect();
@@ -357,31 +424,53 @@ function PanneauVisio({
        </div>
      ) : (
        <>
          <div className="tuiles">
            <div className="tuile" ref={(el) => { conteneurs.current.set("local:camera", el); }}>
              {pisteLocale ? (
          {/* La classe se pose sur la grille, jamais sur l'ordre du tableau :
              déplacer une tuile dans le DOM détacherait sa vidéo, et la
              remonter noire. C'est la mise en page qui bouge, pas les pistes. */}
          <div className={`tuiles${enAvant ? " avant" : ""}`}>
            {tuiles.map((tuile) => (
              <div key={tuile.cle}
                className={"tuile"
                  + (tuile.ecran ? " ecran" : "")
                  + (tuile.cle === enAvant ? " grande" : "")}
                ref={(el) => { conteneurs.current.set(tuile.cle, el); }}>
                {tuile.piste ? (
                  /*
                  Muette, et il faut l'être : sa propre piste renvoyée dans ses
                  haut-parleurs fait un larsen, et un navigateur refuse de lire
                  d'office une vidéo qui ne l'est pas — le retour resterait noir.
                    Ses propres pistes sont muettes, et il faut qu'elles le
                    soient : sa voix renvoyée dans ses haut-parleurs fait un
                    larsen, et un navigateur refuse de lire d'office une vidéo
                    qui ne l'est pas — le retour resterait noir.

                  Retournée, aussi : on se regarde comme dans un miroir. Non
                  retournée, on porte la main à gauche et elle part à droite,
                  ce qui suffit à faire douter de la caméra.
                    Sa caméra est retournée, on se regarde comme dans un
                    miroir. Non retournée, on porte la main à gauche et elle
                    part à droite, ce qui suffit à faire douter de la caméra.
                    Son écran, lui, ne se retourne pas : un document en miroir
                    ne se lit plus.
                  */
                <video autoPlay playsInline muted style={{ transform: "scaleX(-1)" }} />
                  <video autoPlay playsInline muted={tuile.soi}
                    style={tuile.soi && !tuile.ecran
                      ? { transform: "scaleX(-1)" } : undefined} />
                ) : (
                <span className="initiales">{initiales(salle.localParticipant.name ?? "Moi")}</span>
              )}
              <span className="nom">Vous{micro ? "" : t(" · micro coupé")}</span>
            </div>
            {tuiles.map((tuile) => (
              <div key={tuile.cle} className={`tuile${tuile.ecran ? " ecran" : ""}`}
                ref={(el) => { conteneurs.current.set(tuile.cle, el); }}>
                {tuile.piste ? <video autoPlay playsInline /> : (
                  <span className="initiales">{initiales(tuile.nom)}</span>
                )}
                <span className="nom">{tuile.nom}</span>
                <span className="nom">
                  {tuile.libelle}
                  {tuile.soi && !tuile.ecran && !micro ? t(" · micro coupé") : ""}
                </span>
                {/* Le bouton n'apparaît que sur ce qui porte une image : mettre
                    en avant des initiales n'agrandirait rien. */}
                {tuile.piste && (
                  <button className="avancer" aria-pressed={tuile.cle === enAvant}
                    onClick={() => mettreEnAvant(tuile.cle)}
                    title={tuile.cle === enAvant
                      ? t("Remettre cette vue dans la grille")
                      : t("Mettre cette vue en avant")}
                    aria-label={tuile.cle === enAvant
                      ? t("Remettre cette vue dans la grille")
                      : t("Mettre cette vue en avant")}>
                    {tuile.cle === enAvant ? "⤡" : "⤢"}
                  </button>
                )}
              </div>
            ))}
          </div>
+72 −0
Changes for apps/web/src/coque/miseEnAvant.ts: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  Ce qui passe devant dans un appel, et pourquoi (EF-803).                ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * Une grille à parts égales convient à des visages. Elle ne convient pas à une
 * carte, à un tableau de bord, à une main courante qu'on relit ensemble : le
 * document tombait dans un carré de cent soixante pixels de large, où il
 * n'était tout simplement pas lisible.
 *
 * La décision vit ici, hors du composant, parce qu'elle a des règles et que
 * des règles se vérifient. Dans un rendu React on n'aurait pu qu'en constater
 * le texte ; ici on les éprouve.
 */

export interface Vue {
  cle: string;
  ecran: boolean;
}

export interface Choix {
  /** La tuile choisie, ou « null » si l'on a explicitement redemandé la grille. */
  cle: string | null;
  /**
   * Les écrans partagés au moment du choix.
   *
   * C'est ce qui distingue « je ne veux pas de celui-là » de « je ne veux
   * jamais rien devant ». Sans cette mémoire, refuser un partage une fois
   * rendait l'automatisme muet pour le reste de l'appel : la carte que la
   * direction partagerait vingt minutes plus tard resterait dans son carré.
   */
  ecrans: readonly string[];
}

export interface Decision {
  /** La clé de la tuile à mettre en avant, ou « null » pour la grille. */
  avant: string | null;
  /** Le choix manuel, ou « null » quand il est devenu caduc. */
  choix: Choix | null;
}

/**
 * Quatre règles, et pas une de plus.
 *
 * 1. Un choix explicite ne se fait pas défaire tant que sa tuile est là.
 * 2. Une tuile choisie qui disparaît — la personne quitte, le partage
 *    s'arrête — rend la place. Sans cela le premier plan resterait occupé par
 *    un carré vide, ce qui est pire que la grille.
 * 3. Un partage qui commence après un refus passe devant quand même : c'est un
 *    partage qu'on n'a pas refusé, et partager son écran, c'est demander qu'on
 *    le regarde.
 * 4. À défaut de choix, le premier écran partagé — et rien si personne ne
 *    partage. On ne met pas d'office un visage en avant : ce serait désigner
 *    quelqu'un, et ce n'est pas à l'outil de le faire.
 */
export function miseEnAvant(vues: readonly Vue[], choix: Choix | null): Decision {
  const ecrans = vues.filter((v) => v.ecran).map((v) => v.cle);

  if (choix !== null) {
    if (choix.cle !== null) {
      if (vues.some((v) => v.cle === choix.cle)) return { avant: choix.cle, choix };
      // Règle 2 : la tuile choisie n'est plus là, le choix tombe avec elle.
    } else {
      const nouveau = ecrans.find((c) => !choix.ecrans.includes(c));
      if (nouveau === undefined) return { avant: null, choix };
      // Règle 3 : celui-là n'existait pas quand on a redemandé la grille.
      return { avant: nouveau, choix: null };
    }
  }

  return { avant: ecrans[0] ?? null, choix: null };
}
+5 −0
Changes for apps/web/src/langues/en.ts: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1819,6 +1819,11 @@ export const EN: Record<string, string> = {
    "Screen sharing was refused by the browser.",
  "Écran partagé": "Screen shared",
  "Partager l’écran": "Share the screen",
  "Mettre cette vue en avant": "Bring this view to the front",
  "Remettre cette vue dans la grille": "Return this view to the grid",
  "Vous": "You",
  "écran": "screen",
  "Votre écran": "Your screen",
  "Arrêter l’enregistrement de cette réunion": "Stop recording this meeting",
  "Enregistrer cette réunion — le témoin s’allume chez tous les participants":
    "Record this meeting — the indicator lights up for every participant",
+57 −0

File changed.

Preview size limit exceeded, changes collapsed.