Gitfed
bastien-mrq/multi-twitch/ Commits/ d6c9cbd

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>

Bastien Marques 2026-09-07 06:45 commit d6c9cbdc7a3c0c91ba99e8afda233bcccc57fc2c
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
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef93d7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +multi-twitch.zip +.DS_Store +node_modules/
README.md
diff --git a/README.md b/README.md new file mode 100644 index 0000000..0309b17 --- /dev/null +++ b/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. + +![screenshot](docs/screenshot.png) + +## 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
diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..22fa7c1 Binary files /dev/null and b/docs/screenshot.png differ
index.html
diff --git a/index.html b/index.html new file mode 100644 index 0000000..2b21780 --- /dev/null +++ b/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&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>
start.sh
diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..6d75bff --- /dev/null +++ b/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"