Multi-Twitch ZEvent : front local multi-streams (grille sans scroll, mixeur audio, raccourcis clavier, anti-pause/gel/pub)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed
+1344 −0
A
.gitignore
+3 −0
A
README.md
+111 −0
A
docs/screenshot.png
Binary file — not shown.
A
index.html
+1223 −0
A
start.sh
+7 −0
.gitignore
@@ -0,0 +1,3 @@
+multi-twitch.zip
+.DS_Store
+node_modules/
README.md
@@ -0,0 +1,111 @@
+# Multi-Twitch — ZEvent
+
+Front local pour regarder **plusieurs streams Twitch en même temps**, pensé pour le ZEvent :
+tout l'écran est utilisé (pas de scroll, pas de gap), un seul fichier HTML, zéro dépendance.
+
+
+
+## Démarrage rapide
+
+```sh
+./start.sh # sert l'app sur http://localhost:8123 et ouvre le navigateur
+./start.sh 9000 # autre port si besoin
+```
+
+⚠️ Il faut passer par `http://localhost` : le player Twitch exige un paramètre `parent`
+et refuse de se charger depuis un fichier ouvert en `file://`.
+
+## Technologie
+
+- **Un seul fichier `index.html`** : HTML + CSS + JavaScript vanilla, sans framework,
+ sans build, sans npm.
+- Seule brique externe : le **SDK officiel du player Twitch**
+ (`player.twitch.tv/js/embed/v1.js`), qui affiche les streams et expose l'API JS
+ (`play/pause`, `setMuted`, `setVolume`, `setQuality`, `getPlaybackStats`…).
+- **CSS Grid** pour la disposition, **localStorage** pour retenir chaînes et volumes.
+- Serveur : n'importe quel serveur statique (`python3 -m http.server`), aucune logique
+ côté serveur.
+
+## Fonctionnalités
+
+- **Recherche ⌘K** avec autocomplétion sur un annuaire d'habitués du ZEvent
+ (constante `KNOWN` dans `index.html`, modifiable). N'importe quel pseudo Twitch
+ peut être tapé pour ajouter une chaîne hors annuaire.
+- **Grille sans scroll** : le découpage lignes × colonnes est recalculé à chaque
+ redimensionnement pour maximiser la surface vidéo 16:9 (fonction `bestGrid`).
+- **Trois modes** : grille / plein écran avec colonne latérale (`F`) / plein écran (`⌘⇧K`).
+- **Bandeau au survol uniquement** : nom, numéro, boutons (🔊 son solo, slider de volume,
+ ⏯ play/pause, ⛶ plein écran, ✕ fermer). Le reste du temps, 100 % vidéo.
+- **Mixeur de son** : un slider de volume par stream — plusieurs streams audibles en même
+ temps à des niveaux différents. Les touches `1`–`9`/`A` restent le mode « solo »
+ (le son sur un seul stream). Volumes mémorisés.
+- **Jamais de pause** : relance automatique sur événement pause + chien de garde toutes
+ les 2 s + relance à chaque changement de disposition. La pause volontaire (⏯/`Espace`)
+ est respectée (badge « ⏸ PAUSE »).
+- **Anti-gel** : un flux à 0 fps pendant ~6 s alors qu'il devrait jouer voit son player
+ reconstruit automatiquement. La détection ne s'arme qu'après un premier démarrage réel
+ (sinon elle tuerait les streams en cours de chargement).
+- **Anti-pub (best effort)** : pub détectée sur le stream qui a le son → le son (et la
+ mise en avant) bascule sur le stream suivant sans pub, puis revient à la fin.
+ Twitch n'expose pas officiellement cet événement sur les embeds (détection par écoute
+ des messages internes du player), donc `P` fait la même bascule manuellement.
+- **Qualité adaptative** : en mode plein écran/colonne, les petits streams passent en
+ basse qualité pour ne pas saturer le décodeur vidéo (cause classique de vignettes
+ figées) ; le stream principal reste en auto.
+- **Turbo / compte Twitch** : rien à configurer — le player embarqué utilise la session
+ twitch.tv du navigateur (cookies tiers requis : OK sur Chrome par défaut, bloqué sur
+ Safari). Connecté avec Turbo = plus de pubs. Aucun risque de ban : c'est l'usage
+ officiel des embeds.
+
+## Raccourcis clavier
+
+| Touche | Action |
+|---|---|
+| `⌘K` | Recherche / autocomplétion (re-focus si déjà ouverte) |
+| `⌘⇧K` | Plein écran du stream sélectionné (aller-retour) |
+| `F` | Plein écran ⇄ plein écran + colonne latérale |
+| `Entrée` | Met le stream sélectionné en avant |
+| `1`–`9` | Son solo sur le stream n° N |
+| `←` `→` `↑` `↓` | Déplace la sélection dans la grille |
+| `A` | Son solo sur le stream sélectionné |
+| `−` / `+` | Volume du stream sélectionné dans le mix |
+| `Espace` | Play/pause du stream sélectionné |
+| `P` | Pub sur le stream audio → passe au suivant |
+| `M` ou `0` | Coupe tout le son |
+| `X` ou `Suppr` | Ferme le stream sélectionné |
+| `R` | Tout recharger (reconstruit les players, débloque les flux figés) |
+| `Échap` | Retour à la grille |
+
+Tous les raccourcis sont rappelés dans la barre en bas de l'écran.
+Si tu cliques **dans** un player, l'iframe Twitch capte le clavier : le focus est
+automatiquement rendu à la page après ~2,5 s.
+
+## Le piège à connaître : la politique de visibilité de Twitch
+
+La leçon durement apprise de ce projet : **Twitch bloque l'autoplay d'un player
+recouvert par un autre élément, même totalement transparent** (erreur console
+« Autoplay disabled — minimum requirements not met: style visibility »).
+
+Conséquences dans le code :
+
+- les liserés de sélection/son sont des **bordures** de la tuile, jamais des calques
+ posés sur la vidéo ;
+- le bandeau au survol est en `display: none` (pas `opacity: 0`) tant qu'il n'est
+ pas affiché ;
+- en plein écran, les autres streams ne sont pas cachés derrière le principal mais
+ gardés sur une **bande visible de 8 px** au bord droit.
+
+Si tu modifies le CSS, ne pose jamais d'élément permanent au-dessus des players.
+
+## Debug
+
+- `window.__mt` en console expose `tiles`, `state`, `playAll`, `addChannel` pour
+ inspecter l'état réel des players (`__mt.tiles.zerator.player.getPlaybackStats()`).
+- Le comportement a été validé par des tests automatisés en Chrome headless
+ (puppeteer-core) : autoplay, fps > 0 sur chaînes live, absence d'avertissements.
+
+## Fichiers
+
+- `index.html` — toute l'application (CSS + JS inclus)
+- `start.sh` — lance un serveur statique et ouvre le navigateur
+- `README.md` — ce fichier
docs/screenshot.png
index.html
@@ -0,0 +1,1223 @@
+<!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 <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>
start.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Lance le multi-twitch sur http://localhost:8123 (ou ./start.sh <port>)
+cd "$(dirname "$0")"
+PORT="${1:-8123}"
+echo "Multi-Twitch dispo sur http://localhost:$PORT"
+open "http://localhost:$PORT" 2>/dev/null &
+exec python3 -m http.server "$PORT"