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 <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>