Gitfed
bastien-mrq/multi-twitch / index.html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Twitch — ZEvent</title>
<script src="https://player.twitch.tv/js/embed/v1.js"></script>
<style>
  :root {
    --bg: #0e0e10;
    --surface: #18181b;
    --surface-2: #1f1f23;
    --border: #2f2f35;
    --text: #efeff1;
    --text-dim: #adadb8;
    --accent: #9147ff;
    --accent-hover: #772ce8;
    --danger: #eb4d4b;
    --audio: #00c48f;
    --ad: #f0b429;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { height: 100%; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  /* ---------- Barre du haut ---------- */
  header {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 6px 12px;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
    overflow-x: auto;
  }
  header h1 {
    font-size: 15px;
    font-weight: 700;
    white-space: nowrap;
  }
  header h1 .z { color: var(--accent); }
  button {
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    padding: 6px 10px;
    font-size: 13px;
    cursor: pointer;
    white-space: nowrap;
  }
  button:hover { background: var(--border); }
  button.primary { background: var(--accent); border-color: var(--accent); font-weight: 600; }
  button.primary:hover { background: var(--accent-hover); }
  .spacer { flex: 1; }
  header kbd, footer kbd, #palette kbd {
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-bottom-width: 2px;
    border-radius: 4px;
    padding: 0 5px;
    font-size: 11px;
    font-family: inherit;
    color: var(--text);
  }
  #presets {
    display: flex;
    gap: 6px;
    align-items: center;
    overflow-x: auto;
  }
  #presets button {
    padding: 3px 9px;
    font-size: 12px;
    border-radius: 999px;
    color: var(--text-dim);
  }
  #presets button:hover { color: var(--text); border-color: var(--accent); }

  /* ---------- Zone streams (pas de scroll, pas de gap) ---------- */
  main {
    flex: 1;
    min-height: 0;
    padding: 0;
    overflow: hidden;
  }
  #grid {
    display: grid;
    gap: 0;
    height: 100%;
  }
  .tile {
    position: relative;
    background: #000;
    overflow: hidden;
    min-height: 0;
    min-width: 0;
    /* IMPORTANT : les indicateurs sont des bordures de la tuile, jamais des
       éléments PAR-DESSUS la vidéo — Twitch bloque l'autoplay d'un player
       recouvert, même par un élément transparent (« style visibility »). */
    border: 2px solid transparent;
  }
  .tile.has-audio { border-color: var(--audio); }
  .tile.selected { border-color: var(--accent); }
  .tile.selected.has-audio {
    border-image: linear-gradient(135deg, var(--accent) 50%, var(--audio) 50%) 1;
  }
  /* En plein écran, les autres streams restent sur une bande de 8px à droite :
     un player totalement recouvert ou masqué est bloqué/mis en pause par
     Twitch (politique de visibilité), une bande visible non. */
  /* Indicateur pause volontaire */
  .tile.user-paused .tile-header { display: flex; }
  .tile.user-paused .badge.paused { display: inline; color: var(--ad); }
  /* Bandeau : overlay visible uniquement au survol.
     display:none (et non opacity:0) : un overlay transparent compte quand même
     comme une occlusion pour la politique d'autoplay de Twitch. */
  .tile-header {
    position: absolute;
    top: 0; left: 0; right: 0;
    z-index: 4;
    display: none;
    align-items: center;
    gap: 6px;
    height: 30px;
    padding: 0 8px;
    background: linear-gradient(rgba(0,0,0,.82), rgba(0,0,0,0));
    cursor: pointer;
    user-select: none;
    font-size: 12px;
  }
  .tile:hover .tile-header { display: flex; }
  .tile.has-ad .tile-header { display: flex; }
  .tile-header .num {
    font-size: 10px;
    font-weight: 700;
    background: var(--border);
    color: var(--text-dim);
    border-radius: 4px;
    padding: 0 5px;
    flex-shrink: 0;
  }
  .tile.selected .tile-header .num { background: var(--accent); color: #fff; }
  .tile-header .name {
    font-weight: 600;
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
  }
  .tile-header .name a { color: var(--text); text-decoration: none; }
  .tile-header .name a:hover { color: var(--accent); }
  .tile-header .badge { display: none; font-size: 11px; font-weight: 700; flex-shrink: 0; }
  .tile.has-audio .badge.audio { display: inline; color: var(--audio); }
  .tile.has-ad .badge.ad { display: inline; color: var(--ad); }
  .tile-header button {
    padding: 1px 6px;
    font-size: 11px;
    border: none;
    background: transparent;
    color: var(--text-dim);
    flex-shrink: 0;
  }
  .tile-header button:hover { color: var(--text); background: var(--border); }
  .tile-header button.close:hover { color: var(--danger); }
  .tile-header input[type="range"] {
    width: 74px;
    flex-shrink: 0;
    accent-color: var(--audio);
    cursor: pointer;
  }
  .player-wrap {
    position: absolute;
    inset: 0;
    background: #000;
  }
  .player-wrap iframe {
    position: absolute;
    inset: 0;
    width: 100% !important;
    height: 100% !important;
    border: 0;
  }

  /* ---------- Palette de recherche (⌘K) ---------- */
  #palette {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, .55);
    display: none;
    align-items: flex-start;
    justify-content: center;
    padding-top: 12vh;
    z-index: 100;
  }
  #palette.open { display: flex; }
  .palette-box {
    width: min(560px, 92vw);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: 0 16px 60px rgba(0, 0, 0, .6);
    overflow: hidden;
  }
  #palette-input {
    width: 100%;
    background: transparent;
    border: none;
    border-bottom: 1px solid var(--border);
    color: var(--text);
    padding: 14px 16px;
    font-size: 16px;
    outline: none;
  }
  #palette-list {
    list-style: none;
    max-height: 46vh;
    overflow-y: auto;
  }
  #palette-list li {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 9px 16px;
    cursor: pointer;
    font-size: 14px;
  }
  #palette-list li.active { background: var(--accent); }
  #palette-list li .handle { color: var(--text-dim); font-size: 12px; }
  #palette-list li.active .handle { color: rgba(255,255,255,.8); }
  #palette-list li .tag {
    margin-left: auto;
    font-size: 11px;
    color: var(--audio);
    flex-shrink: 0;
  }
  #palette-list li.active .tag { color: #fff; }
  .palette-help {
    padding: 8px 16px;
    font-size: 11px;
    color: var(--text-dim);
    border-top: 1px solid var(--border);
    display: flex;
    gap: 12px;
  }

  /* ---------- Toast ---------- */
  #toast {
    position: fixed;
    bottom: 44px;
    left: 50%;
    transform: translateX(-50%);
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 8px 16px;
    font-size: 13px;
    opacity: 0;
    transition: opacity .25s;
    pointer-events: none;
    z-index: 200;
    white-space: nowrap;
  }
  #toast.show { opacity: 1; }

  /* ---------- Barre de raccourcis ---------- */
  footer {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 4px 12px;
    background: var(--surface);
    border-top: 1px solid var(--border);
    font-size: 11px;
    color: var(--text-dim);
    flex-shrink: 0;
    overflow-x: auto;
    white-space: nowrap;
  }
  footer .hint { margin-left: auto; font-style: italic; }

  /* ---------- État vide / avertissement ---------- */
  #empty {
    text-align: center;
    color: var(--text-dim);
    padding-top: 18vh;
    font-size: 15px;
    line-height: 2;
  }
  #empty strong { color: var(--text); }
  #file-warning {
    display: none;
    background: #4d2020;
    border: 1px solid var(--danger);
    color: #ffd7d7;
    padding: 12px 16px;
    margin: 10px;
    border-radius: 8px;
    font-size: 14px;
    line-height: 1.6;
  }
  #file-warning code {
    background: rgba(0,0,0,.35);
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 13px;
  }
</style>
</head>
<body>

<header>
  <h1><span class="z">Z</span>Multi-Twitch</h1>
  <button class="primary" id="open-palette">🔎 Rechercher&nbsp;&nbsp;<kbd>⌘K</kbd></button>
  <div id="presets"><!-- rempli en JS --></div>
  <div class="spacer"></div>
  <button id="play-all" title="Relancer la lecture de tous les streams">▶ Tout relancer</button>
  <button id="clear-all" title="Retirer tous les streams">Tout retirer</button>
</header>

<div id="file-warning">
  ⚠️ <strong>Le player Twitch ne fonctionne pas en ouvrant ce fichier directement.</strong><br>
  Lance <code>./start.sh</code> puis ouvre <code>http://localhost:8123</code>
</div>

<main>
  <div id="grid" class="mode-grid"></div>
  <div id="empty" style="display:none">
    <strong>Aucun stream.</strong><br>
    <kbd>⌘K</kbd> pour chercher un streamer, ou clique sur une suggestion en haut.
  </div>
</main>

<footer>
  <span><kbd>⌘K</kbd> recherche</span>
  <span><kbd>1</kbd>–<kbd>9</kbd> son au n°</span>
  <span><kbd>←→↑↓</kbd> sélection</span>
  <span><kbd>A</kbd> son solo</span>
  <span><kbd>−</kbd><kbd>+</kbd> volume (mix)</span>
  <span><kbd>Espace</kbd> play/pause</span>
  <span><kbd>⌘⇧K</kbd> plein écran</span>
  <span><kbd>F</kbd> plein écran ⇄ avec colonne</span>
  <span><kbd>Entrée</kbd> mettre en avant</span>
  <span><kbd>P</kbd> pub → stream suivant</span>
  <span><kbd>M</kbd> muet</span>
  <span><kbd>X</kbd> fermer le stream</span>
  <span><kbd>R</kbd> tout recharger</span>
  <span><kbd>Échap</kbd> grille</span>
  <span class="hint" id="focus-hint"></span>
</footer>

<div id="palette">
  <div class="palette-box">
    <input id="palette-input" type="text" placeholder="Chercher un streamer… (nom ou pseudo Twitch)"
           autocomplete="off" spellcheck="false">
    <ul id="palette-list"></ul>
    <div class="palette-help">
      <span><kbd>↑↓</kbd> naviguer</span>
      <span><kbd>Entrée</kbd> ajouter / sélectionner</span>
      <span><kbd>Échap</kbd> fermer</span>
    </div>
  </div>
</div>

<div id="toast"></div>

<script>
(function () {
  "use strict";

  var STORAGE_KEY = "multitwitch-state-v1";
  var PARENT = window.location.hostname || "localhost";
  var HAS_API = typeof window.Twitch !== "undefined" && window.Twitch.Player;
  var HEADER_H = 0, GAP = 0; // bandeau en overlay et pas de gap : toute la surface est vidéo

  // Annuaire : habitués du ZEvent (pseudo Twitch + nom d'affichage).
  // Sert aux suggestions et à l'autocomplete ⌘K — modifie librement.
  var KNOWN = [
    ["zerator", "ZeratoR"],
    ["sylvainlyve", "Sylvain Lyve"],
    ["joueur_du_grenier", "Joueur du Grenier (JDG)"],
    ["sebjdg", "Seb du Grenier"],
    ["boblennon", "Bob Lennon"],
    ["antoinedaniel", "Antoine Daniel"],
    ["ponce", "Ponce"],
    ["domingo", "Domingo"],
    ["mistermv", "MisterMV"],
    ["kamet0", "Kameto"],
    ["etoiles", "Etoiles"],
    ["angledroit", "AngleDroit"],
    ["rebeudeter", "RebeuDeter"],
    ["gotaga", "Gotaga"],
    ["bagherajones", "Baghera Jones"],
    ["ultia", "Ultia"],
    ["maghla", "Maghla"],
    ["littlebigwhale", "LittleBigWhale"],
    ["samueletienne", "Samuel Étienne"],
    ["jeanmassiet", "Jean Massiet"],
    ["alphacast", "AlphaCast"],
    ["wankilstudio", "Wankil Studio (Laink & Terracid)"],
    ["aypierre", "Aypierre"],
    ["jltomy", "JLTomy"],
    ["locklear", "Locklear"],
    ["sardoche", "Sardoche"],
    ["rivenzi", "Rivenzi"],
    ["hortyunderscore", "Horty"],
    ["chowh1", "ChowH1"],
    ["helydia", "Helydia"],
    ["deujna", "DeuJNa"],
    ["fugu_fps", "Fugu"],
    ["shaunz", "Shaunz"],
    ["mickalow", "Mickalow"],
    ["kennystream", "Kenny"],
    ["theguill84", "TheGuill84"],
    ["kenbogard", "Ken Bogard"],
    ["avamind", "AvaMind"],
    ["xsqueezie", "Squeezie"],
    ["aminematue", "Amine"],
    ["inoxtag", "Inoxtag"],
    ["kaatsup", "Kaatsup"],
    ["doigby", "Doigby"]
  ];
  // Raccourcis affichés dans la barre du haut
  var PRESETS = ["zerator", "sylvainlyve", "joueur_du_grenier", "sebjdg", "boblennon",
                 "antoinedaniel", "ponce", "domingo", "mistermv", "kamet0", "etoiles"];

  var grid = document.getElementById("grid");
  var empty = document.getElementById("empty");
  var clearBtn = document.getElementById("clear-all");
  var presetsBar = document.getElementById("presets");
  var focusHint = document.getElementById("focus-hint");
  var palette = document.getElementById("palette");
  var paletteInput = document.getElementById("palette-input");
  var paletteList = document.getElementById("palette-list");
  var toastEl = document.getElementById("toast");

  // name -> { tile, player, playerWrap, entry }
  var tiles = {};
  var selectedName = null;   // contour violet
  var audioName = null;      // stream qui a le son
  var featuredName = null;   // stream mis en avant
  var mode = "grid";         // grid | focus | solo
  var adSwitch = null;       // {from, to} : bascule auto pour cause de pub
  var adState = {};          // name -> bool (pub en cours détectée)

  // ---------- État persistant ----------
  var state = load();

  function load() {
    try {
      var raw = localStorage.getItem(STORAGE_KEY);
      if (raw) {
        var s = JSON.parse(raw);
        if (s && Array.isArray(s.channels)) {
          return { channels: s.channels.map(function (c) {
            return { name: c.name, vol: (typeof c.vol === "number") ? c.vol : 0.7 };
          }) };
        }
      }
    } catch (e) {}
    return { channels: [] };
  }

  function save() {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
  }

  if (window.location.protocol === "file:") {
    document.getElementById("file-warning").style.display = "block";
  }

  // ---------- Helpers ----------
  function normalize(name) {
    return name.trim().toLowerCase().replace(/^@/, "")
      .replace(/^https?:\/\/(www\.)?twitch\.tv\//, "")
      .split(/[/?#]/)[0];
  }
  function validName(name) { return /^[a-z0-9_]{3,25}$/.test(name); }
  function indexOfChannel(name) {
    for (var i = 0; i < state.channels.length; i++) {
      if (state.channels[i].name === name) return i;
    }
    return -1;
  }
  function displayName(handle) {
    for (var i = 0; i < KNOWN.length; i++) {
      if (KNOWN[i][0] === handle) return KNOWN[i][1];
    }
    return handle;
  }
  function toast(msg) {
    toastEl.textContent = msg;
    toastEl.classList.add("show");
    clearTimeout(toastEl._t);
    toastEl._t = setTimeout(function () { toastEl.classList.remove("show"); }, 2600);
  }

  // ---------- Tuiles ----------
  function makeTile(entry) {
    var tile = document.createElement("div");
    tile.className = "tile";
    tile.dataset.channel = entry.name;

    var header = document.createElement("div");
    header.className = "tile-header";

    var num = document.createElement("span");
    num.className = "num";

    var name = document.createElement("span");
    name.className = "name";
    var link = document.createElement("a");
    link.href = "https://www.twitch.tv/" + encodeURIComponent(entry.name);
    link.target = "_blank";
    link.rel = "noopener";
    link.textContent = displayName(entry.name);
    link.title = entry.name;
    name.appendChild(link);

    var adBadge = document.createElement("span");
    adBadge.className = "badge ad";
    adBadge.textContent = "PUB";

    var audioBadge = document.createElement("span");
    audioBadge.className = "badge audio";
    audioBadge.textContent = "🔊";

    var pausedBadge = document.createElement("span");
    pausedBadge.className = "badge paused";
    pausedBadge.textContent = "⏸ PAUSE";

    var playBtn = document.createElement("button");
    playBtn.textContent = "⏯";
    playBtn.title = "Play / Pause (Espace)";

    var audioBtn = document.createElement("button");
    audioBtn.textContent = "🔊";
    audioBtn.title = "Son solo : ce stream seul (A)";

    var slider = document.createElement("input");
    slider.type = "range";
    slider.min = "0";
    slider.max = "100";
    slider.value = String(Math.round((entry.vol || 0.7) * 100));
    slider.title = "Volume de ce stream dans le mix (−/+)";

    var featBtn = document.createElement("button");
    featBtn.textContent = "⛶";
    featBtn.title = "Mettre en avant / plein écran (Entrée, F)";

    var closeBtn = document.createElement("button");
    closeBtn.className = "close";
    closeBtn.textContent = "✕";
    closeBtn.title = "Retirer ce stream";

    header.appendChild(num);
    header.appendChild(name);
    header.appendChild(adBadge);
    header.appendChild(pausedBadge);
    header.appendChild(audioBadge);
    header.appendChild(playBtn);
    header.appendChild(audioBtn);
    header.appendChild(slider);
    header.appendChild(featBtn);
    header.appendChild(closeBtn);

    var playerWrap = document.createElement("div");
    playerWrap.className = "player-wrap";
    playerWrap.id = "player-" + entry.name;

    tile.appendChild(header);
    tile.appendChild(playerWrap);
    grid.appendChild(tile);

    tiles[entry.name] = {
      tile: tile, player: null, playerWrap: playerWrap, entry: entry,
      muted: true, slider: slider, userPaused: false, offline: false, frozen: 0
    };
    buildPlayer(entry.name);

    slider.addEventListener("click", function (e) { e.stopPropagation(); });
    slider.addEventListener("input", function (e) {
      e.stopPropagation();
      setVolume(entry.name, parseInt(slider.value, 10) / 100);
    });

    header.addEventListener("click", function () { select(entry.name); });
    playBtn.addEventListener("click", function (e) {
      e.stopPropagation();
      togglePause(entry.name);
    });
    audioBtn.addEventListener("click", function (e) {
      e.stopPropagation();
      select(entry.name);
      setAudio(entry.name);
    });
    featBtn.addEventListener("click", function (e) {
      e.stopPropagation();
      select(entry.name);
      if (featuredName === entry.name) {
        setMode(mode === "solo" ? "focus" : "solo", entry.name);
      } else {
        setMode(mode === "grid" ? "focus" : mode, entry.name);
      }
    });
    closeBtn.addEventListener("click", function (e) {
      e.stopPropagation();
      removeChannel(entry.name);
    });

    return tile;
  }

  // Crée (ou recrée) le player d'une tuile. Recréer est le seul remède fiable
  // quand un flux se fige (image immobile, insensible à play/pause).
  function buildPlayer(name) {
    var t = tiles[name];
    if (!t) return;
    t.playerWrap.innerHTML = "";
    t.player = null;
    t.frozen = 0;
    t.hasPlayed = false; // l'anti-gel ne s'arme qu'après un premier démarrage réel
    if (!HAS_API) {
      var f = document.createElement("iframe");
      f.src = "https://player.twitch.tv/?channel=" + encodeURIComponent(name) +
              "&parent=" + encodeURIComponent(PARENT) + "&muted=true&autoplay=true";
      f.allowFullscreen = true;
      f.allow = "autoplay; fullscreen";
      t.playerWrap.appendChild(f);
      return;
    }
    var player = new Twitch.Player(t.playerWrap.id, {
      channel: name,
      parent: [PARENT],
      width: "100%",
      height: "100%",
      muted: true,
      autoplay: true
    });
    t.player = player;
    // Jamais de pause (sauf pause volontaire via ⏯).
    function forcePlay() {
      if (t.userPaused) return;
      try { player.play(); } catch (e) {}
      setTimeout(function () {
        if (t.userPaused) return;
        try { if (player.isPaused()) player.play(); } catch (e) {}
      }, 800);
    }
    player.addEventListener(Twitch.Player.PAUSE, function () { setTimeout(forcePlay, 200); });
    player.addEventListener(Twitch.Player.PLAYING, function () {
      t.hasPlayed = true;
      t.frozen = 0;
    });
    player.addEventListener(Twitch.Player.READY, function () {
      forcePlay();
      applyAudio();      // volume/mute réappliqués après (re)construction
      applyQualities();
    });
    if (Twitch.Player.ONLINE) {
      player.addEventListener(Twitch.Player.ONLINE, function () { t.offline = false; forcePlay(); });
    }
    if (Twitch.Player.OFFLINE) {
      player.addEventListener(Twitch.Player.OFFLINE, function () { t.offline = true; });
    }
  }

  function renderNums() {
    state.channels.forEach(function (entry, i) {
      var t = tiles[entry.name];
      if (t) t.tile.querySelector(".num").textContent = String(i + 1);
    });
  }

  // ---------- Disposition sans scroll, optimisée au ratio ----------
  function bestGrid(n, W, H) {
    // Choisit le nb de colonnes qui maximise la surface vidéo 16:9 par tuile.
    var best = { cols: 1, rows: n, area: -1 };
    for (var c = 1; c <= n; c++) {
      var r = Math.ceil(n / c);
      var cellW = (W - (c - 1) * GAP) / c;
      var cellH = (H - (r - 1) * GAP) / r - HEADER_H;
      if (cellW <= 0 || cellH <= 0) continue;
      var vw = Math.min(cellW, cellH * 16 / 9);
      var vh = vw * 9 / 16;
      var area = vw * vh;
      if (area > best.area) best = { cols: c, rows: r, area: area };
    }
    return best;
  }

  function layout() {
    var n = state.channels.length;
    empty.style.display = n ? "none" : "block";
    grid.className = "mode-" + mode;
    grid.style.gridTemplateColumns = "";
    grid.style.gridTemplateRows = "";
    state.channels.forEach(function (e) {
      var t = tiles[e.name];
      if (t) { t.tile.style.gridArea = ""; t.tile.classList.toggle("featured", e.name === featuredName); }
    });
    if (!n) return;

    var main = document.querySelector("main");
    var W = main.clientWidth, H = main.clientHeight;

    if ((mode === "solo" || mode === "focus") && featuredName) {
      if (n === 1) {
        grid.style.gridTemplateColumns = "1fr";
        grid.style.gridTemplateRows = "1fr";
        return;
      }
      // solo : bande de 8px (les autres restent visibles, jamais recouverts,
      // sinon Twitch bloque leur lecture) ; focus : vraie colonne latérale.
      var side = (mode === "solo")
        ? 8
        : Math.max(180, Math.min(320, Math.round(W * 0.16)));
      grid.style.gridTemplateColumns = "1fr " + side + "px";
      grid.style.gridTemplateRows = "repeat(" + (n - 1) + ", 1fr)";
      var row = 1;
      state.channels.forEach(function (e) {
        var t = tiles[e.name];
        if (!t) return;
        if (e.name === featuredName) {
          t.tile.style.gridArea = "1 / 1 / " + n + " / 2";
        } else {
          t.tile.style.gridArea = row + " / 2 / " + (row + 1) + " / 3";
          row++;
        }
      });
      return;
    }

    // Mode grille : calcul du meilleur découpage pour l'écran courant.
    var g = bestGrid(n, W, H);
    grid.style.gridTemplateColumns = "repeat(" + g.cols + ", 1fr)";
    grid.style.gridTemplateRows = "repeat(" + g.rows + ", 1fr)";
    grid._cols = g.cols;
  }

  function setMode(newMode, name) {
    if (newMode !== "grid" && !name) name = selectedName || (state.channels[0] && state.channels[0].name);
    if (newMode !== "grid" && !name) return;
    mode = newMode;
    featuredName = (newMode === "grid") ? null : name;
    layout();
    // Un changement de disposition peut faire décrocher certains players :
    // on force la lecture tout de suite puis on revérifie.
    playAll();
    applyQualities();
    setTimeout(playAll, 700);
    setTimeout(playAll, 2000);
  }

  // En mode plein écran / colonne, les petits streams passent en basse qualité :
  // trop de flux HD simultanés saturent le décodeur vidéo et les vignettes gèlent.
  function applyQualities() {
    Object.keys(tiles).forEach(function (n) {
      var t = tiles[n];
      if (!t.player) return;
      try {
        if (mode !== "grid" && n !== featuredName) {
          var qs = t.player.getQualities() || [];
          var low = qs[qs.length - 1]; // la dernière est la plus basse
          if (low) t.player.setQuality(low.group || low.name);
        } else {
          t.player.setQuality("auto");
        }
      } catch (e) {}
    });
  }

  function playAll() {
    // play() inconditionnel : isPaused() peut se désynchroniser (colonne/plein
    // écran), et play() sur un stream déjà en lecture est sans effet.
    Object.keys(tiles).forEach(function (n) {
      var t = tiles[n];
      if (!t.player || t.userPaused) return;
      try { t.player.play(); } catch (e) {}
    });
  }

  function togglePause(name) {
    var t = tiles[name];
    if (!t || !t.player) return;
    t.userPaused = !t.userPaused;
    t.tile.classList.toggle("user-paused", t.userPaused);
    try { t.userPaused ? t.player.pause() : t.player.play(); } catch (e) {}
  }

  // ---------- Sélection / audio ----------
  function select(name) {
    if (selectedName && tiles[selectedName]) tiles[selectedName].tile.classList.remove("selected");
    selectedName = name;
    if (name && tiles[name]) tiles[name].tile.classList.add("selected");
  }

  function selectByOffset(delta) {
    if (!state.channels.length) return;
    var i = selectedName ? indexOfChannel(selectedName) : -1;
    i = (i + delta % state.channels.length + state.channels.length) % state.channels.length;
    select(state.channels[i].name);
  }

  // Applique l'état du mix (mute + volume par stream) aux players.
  function applyAudio() {
    Object.keys(tiles).forEach(function (n) {
      var t = tiles[n];
      var on = !t.muted && t.entry.vol > 0;
      t.tile.classList.toggle("has-audio", on);
      if (t.player) {
        try {
          t.player.setVolume(t.entry.vol);
          t.player.setMuted(t.muted);
        } catch (e) {}
      }
    });
  }

  // Mode "solo" : le son sur UN stream, tous les autres muets (touches 1-9 / A).
  function setAudio(name, opts) {
    if (!opts || !opts.auto) adSwitch = null; // changement manuel : on oublie le retour auto
    audioName = (name && tiles[name]) ? name : null;
    Object.keys(tiles).forEach(function (n) {
      var t = tiles[n];
      t.muted = (n !== audioName);
      if (n === audioName && t.entry.vol === 0) {
        t.entry.vol = 0.7;
        t.slider.value = "70";
      }
    });
    save();
    applyAudio();
  }

  // Mixeur : règle le volume d'UN stream sans toucher aux autres (sliders / − +).
  function setVolume(name, vol) {
    var t = tiles[name];
    if (!t) return;
    t.entry.vol = Math.max(0, Math.min(1, vol));
    t.muted = t.entry.vol === 0;
    t.slider.value = String(Math.round(t.entry.vol * 100));
    if (!audioName && !t.muted) audioName = name;
    save();
    applyAudio();
  }

  // ---------- Pub : détection + bascule ----------
  function nextWithoutAd(fromName) {
    var i = indexOfChannel(fromName);
    for (var k = 1; k < state.channels.length; k++) {
      var c = state.channels[(i + k) % state.channels.length].name;
      if (!adState[c]) return c;
    }
    return null;
  }

  function switchForAd(fromName, manual) {
    var to = nextWithoutAd(fromName);
    if (!to) return;
    adSwitch = manual ? null : { from: fromName, to: to };
    setAudio(to, { auto: true });
    select(to);
    if (mode !== "grid" && featuredName === fromName) setMode(mode, to);
    toast((manual ? "Pub sur " : "Pub détectée sur ") + displayName(fromName) +
          " → passage sur " + displayName(to));
  }

  function markAd(name, on) {
    if (!tiles[name] || adState[name] === on) return;
    adState[name] = on;
    tiles[name].tile.classList.toggle("has-ad", on);
    if (on && name === audioName) {
      switchForAd(name, false);
    } else if (!on && adSwitch && adSwitch.from === name && audioName === adSwitch.to) {
      var back = adSwitch.from;
      adSwitch = null;
      setAudio(back, { auto: true });
      select(back);
      if (mode !== "grid") setMode(mode, back);
      toast("Fin de pub → retour sur " + displayName(back));
    }
  }

  // Détection best-effort : on écoute les messages que les players Twitch
  // envoient à la page et on y cherche des signaux de pub.
  window.addEventListener("message", function (e) {
    if (!e.origin || e.origin.indexOf("player.twitch.tv") === -1) return;
    var s;
    try { s = JSON.stringify(e.data); } catch (err) { return; }
    if (!s || !/ad|commercial/i.test(s)) return;
    var name = null;
    Object.keys(tiles).forEach(function (n) {
      var f = tiles[n].playerWrap.querySelector("iframe");
      if (f && f.contentWindow === e.source) name = n;
    });
    if (!name) return;
    if (/adstart|ad_?start|"ads?"\s*:\s*true|adplaying"?\s*:\s*true|commercial/i.test(s)) {
      markAd(name, true);
    }
    if (/adend|ad_?end|ad_?completed|"ads?"\s*:\s*false|adplaying"?\s*:\s*false/i.test(s)) {
      markAd(name, false);
    }
  });

  // ---------- Chien de garde : anti-pause + détection de gel ----------
  setInterval(function () {
    if (document.hidden) return;
    playAll();
    Object.keys(tiles).forEach(function (n) {
      var t = tiles[n];
      if (!t.player || !t.hasPlayed || t.userPaused || t.offline || adState[n]) { t.frozen = 0; return; }
      try {
        var st = (typeof t.player.getPlaybackStats === "function")
          ? t.player.getPlaybackStats() : null;
        if (st && typeof st.fps === "number" && st.fps === 0 && !t.player.isPaused()) {
          t.frozen++;
          if (t.frozen >= 3) { // ~6 s d'image figée → on reconstruit le player
            toast("Flux figé sur " + displayName(n) + " → rechargement");
            buildPlayer(n);
          }
        } else {
          t.frozen = 0;
        }
      } catch (e) {}
    });
  }, 2000);

  // ---------- Ajout / retrait ----------
  function addChannel(rawName) {
    var name = normalize(rawName);
    if (!validName(name)) return false;
    if (indexOfChannel(name) !== -1) return true;
    var entry = { name: name, vol: 0.7 };
    state.channels.push(entry);
    save();
    makeTile(entry);
    renderNums();
    layout();
    if (!selectedName) select(name);
    return true;
  }

  function removeChannel(name) {
    var idx = indexOfChannel(name);
    if (idx === -1) return;
    state.channels.splice(idx, 1);
    save();
    var t = tiles[name];
    if (t) { t.tile.remove(); delete tiles[name]; }
    delete adState[name];
    if (audioName === name) audioName = null;
    if (featuredName === name) {
      featuredName = null;
      mode = "grid";
    }
    if (selectedName === name) {
      selectedName = null;
      if (state.channels.length) {
        select(state.channels[Math.min(idx, state.channels.length - 1)].name);
      }
    }
    renderNums();
    layout();
  }

  // ---------- Palette ⌘K ----------
  var paletteItems = [];   // items affichés : {handle, label, added, addNew}
  var paletteIndex = 0;

  function openPalette() {
    palette.classList.add("open");
    paletteInput.focus();
    paletteInput.select();
    renderPalette();
  }
  function closePalette() {
    palette.classList.remove("open");
    paletteInput.blur();
  }

  function renderPalette() {
    var q = normalize(paletteInput.value || "");
    var seen = {};
    var items = [];

    function push(handle, label) {
      if (seen[handle]) return;
      seen[handle] = true;
      items.push({ handle: handle, label: label, added: indexOfChannel(handle) !== -1 });
    }

    var pool = KNOWN.slice();
    state.channels.forEach(function (c) {
      if (!pool.some(function (k) { return k[0] === c.name; })) pool.push([c.name, c.name]);
    });

    if (!q) {
      pool.forEach(function (k) { push(k[0], k[1]); });
    } else {
      // ceux qui commencent par la requête d'abord, puis ceux qui la contiennent
      pool.forEach(function (k) {
        var h = k[0], d = k[1].toLowerCase().replace(/\s/g, "");
        if (h.indexOf(q) === 0 || d.indexOf(q) === 0) push(k[0], k[1]);
      });
      pool.forEach(function (k) {
        var h = k[0], d = k[1].toLowerCase();
        if (h.indexOf(q) !== -1 || d.indexOf(q) !== -1) push(k[0], k[1]);
      });
      if (validName(q) && !seen[q]) {
        items.push({ handle: q, label: "Ajouter « " + q + " »", added: false, addNew: true });
      }
    }

    paletteItems = items.slice(0, 12);
    if (paletteIndex >= paletteItems.length) paletteIndex = 0;

    paletteList.innerHTML = "";
    paletteItems.forEach(function (it, i) {
      var li = document.createElement("li");
      if (i === paletteIndex) li.classList.add("active");
      var label = document.createElement("span");
      label.textContent = it.label;
      var handle = document.createElement("span");
      handle.className = "handle";
      handle.textContent = it.addNew ? "" : it.handle;
      var tag = document.createElement("span");
      tag.className = "tag";
      tag.textContent = it.added ? "✓ à l'écran — Entrée : son" : "";
      li.appendChild(label);
      li.appendChild(handle);
      li.appendChild(tag);
      li.addEventListener("click", function () { pickPalette(i); });
      li.addEventListener("mousemove", function () {
        if (paletteIndex !== i) { paletteIndex = i; renderPalette(); }
      });
      paletteList.appendChild(li);
    });
  }

  function pickPalette(i) {
    var it = paletteItems[i];
    if (!it) return;
    if (it.added) {
      select(it.handle);
      setAudio(it.handle);
      if (mode !== "grid") setMode(mode, it.handle);
    } else {
      if (!addChannel(it.handle)) return;
      select(it.handle);
    }
    closePalette();
  }

  paletteInput.addEventListener("input", function () { paletteIndex = 0; renderPalette(); });
  paletteInput.addEventListener("keydown", function (e) {
    if (e.key === "ArrowDown") {
      paletteIndex = Math.min(paletteIndex + 1, paletteItems.length - 1);
      renderPalette(); e.preventDefault();
    } else if (e.key === "ArrowUp") {
      paletteIndex = Math.max(paletteIndex - 1, 0);
      renderPalette(); e.preventDefault();
    } else if (e.key === "Enter") {
      pickPalette(paletteIndex); e.preventDefault();
    } else if (e.key === "Escape") {
      closePalette();
    }
  });
  palette.addEventListener("click", function (e) {
    if (e.target === palette) closePalette();
  });
  document.getElementById("open-palette").addEventListener("click", openPalette);

  // ---------- Raccourcis clavier globaux ----------
  document.addEventListener("keydown", function (e) {
    // ⌘K / ⌘⇧K passent avant tout (même depuis un champ)
    if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
      e.preventDefault();
      if (e.shiftKey) {
        // ⌘⇧K : plein écran du stream sélectionné (aller-retour)
        if (!selectedName) return;
        if (mode === "solo" && featuredName === selectedName) setMode("grid");
        else setMode("solo", selectedName);
      } else {
        openPalette(); // ouvre, ou re-focus si déjà ouverte
      }
      return;
    }

    if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" ||
                     e.target.tagName === "SELECT")) {
      return;
    }
    if (e.metaKey || e.ctrlKey || e.altKey) return;

    if (e.key >= "1" && e.key <= "9") {
      var i = parseInt(e.key, 10) - 1;
      if (i < state.channels.length) {
        var name = state.channels[i].name;
        select(name);
        setAudio(name);
      }
      e.preventDefault();
      return;
    }

    var cols = grid._cols || 1;
    switch (e.key) {
      case "ArrowRight": selectByOffset(1); e.preventDefault(); break;
      case "ArrowLeft": selectByOffset(-1); e.preventDefault(); break;
      case "ArrowDown": selectByOffset(mode === "grid" ? cols : 1); e.preventDefault(); break;
      case "ArrowUp": selectByOffset(mode === "grid" ? -cols : -1); e.preventDefault(); break;
      case "a": case "A":
        if (selectedName) setAudio(audioName === selectedName ? null : selectedName);
        break;
      case "Enter":
        if (selectedName) setMode(mode === "grid" ? "focus" : mode, selectedName);
        e.preventDefault();
        break;
      case "f": case "F":
        // bascule plein écran ⇄ plein écran + colonne
        if (featuredName) setMode(mode === "solo" ? "focus" : "solo", featuredName);
        else if (selectedName) setMode("focus", selectedName);
        e.preventDefault();
        break;
      case " ":
        if (selectedName) togglePause(selectedName);
        e.preventDefault();
        break;
      case "-": case "_":
        if (selectedName) setVolume(selectedName, tiles[selectedName].entry.vol - 0.1);
        break;
      case "+": case "=":
        if (selectedName) setVolume(selectedName, tiles[selectedName].entry.vol + 0.1);
        break;
      case "p": case "P":
        if (audioName) switchForAd(audioName, true);
        break;
      case "r": case "R":
        reloadAll();
        break;
      case "m": case "M": case "0":
        setAudio(null);
        break;
      case "x": case "X": case "Backspace": case "Delete":
        if (selectedName) removeChannel(selectedName);
        e.preventDefault();
        break;
      case "Escape":
        setMode("grid");
        break;
    }
  });

  // Un clic DANS un player donne le focus à l'iframe Twitch et coupe nos raccourcis.
  // Plus de chat = jamais besoin de taper dedans : on reprend le focus après 2,5 s.
  var iframeFocusTicks = 0;
  setInterval(function () {
    var inIframe = document.activeElement && document.activeElement.tagName === "IFRAME";
    if (inIframe) {
      iframeFocusTicks++;
      if (iframeFocusTicks >= 3) {
        document.activeElement.blur();
        window.focus();
        iframeFocusTicks = 0;
      }
    } else {
      iframeFocusTicks = 0;
    }
    focusHint.textContent = (inIframe && iframeFocusTicks > 0)
      ? "focus dans un player… raccourcis de retour dans un instant"
      : "";
  }, 850);

  // ---------- UI ----------
  function reloadAll() {
    if (!state.channels.length) return;
    Object.keys(tiles).forEach(function (n) {
      tiles[n].userPaused = false;
      tiles[n].tile.classList.remove("user-paused");
      buildPlayer(n); // reconstruction complète : débloque aussi les flux figés
    });
    toast("Tous les players ont été rechargés");
  }
  document.getElementById("play-all").addEventListener("click", reloadAll);

  clearBtn.addEventListener("click", function () {
    if (!state.channels.length) return;
    if (!confirm("Retirer tous les streams ?")) return;
    state.channels.slice().forEach(function (c) { removeChannel(c.name); });
  });

  PRESETS.forEach(function (p) {
    var b = document.createElement("button");
    b.type = "button";
    b.textContent = displayName(p);
    b.title = p;
    b.addEventListener("click", function () { addChannel(p); });
    presetsBar.appendChild(b);
  });

  window.addEventListener("resize", layout);

  // Hook de debug/test (inspection de l'état des players depuis la console)
  window.__mt = { tiles: tiles, state: state, playAll: playAll, addChannel: addChannel };

  // ---------- Init ----------
  state.channels.forEach(function (entry) { makeTile(entry); });
  renderNums();
  layout();
  if (state.channels.length) select(state.channels[0].name);
})();
</script>
</body>
</html>