JVChat Mobile

Version mobile de JVChat (fork SVR) — chat seul, optimisé Firefox Android.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name           JVChat Mobile
// @description    Version mobile de JVChat (fork SVR) — chat seul, optimisé Firefox Android.
// @author         DarkWizounet
// @namespace      JVChat-Mobile
// @license        MIT
// @version        7.8.7
// @match          https://*.jeuxvideo.com/forums/42-*
// @match          https://*.jeuxvideo.com/forums/1-*
// @match          https://*.jeuxvideo.com/forums/0-42-*
// @match          https://*.jeuxvideo.com/forums/0-19163-*
// @grant          GM_xmlhttpRequest
// @connect        esports-api.lolesports.com
// @connect        feed.lolesports.com
// @connect        api.bo3.gg
// ==/UserScript==

const _DBG = false;
function dbg(...a) { if (_DBG) console.log('%c[JVChat]', 'color:#00e5ff;font-weight:bold', ...a); }
function err(...a) { if (_DBG) console.error('%c[JVChat]', 'color:#ff1744;font-weight:bold', ...a); }

// ─────────────────────────────────────────────────────────────
// CACHE AVATARS (en mémoire, évite les rechargements réseau)
// ─────────────────────────────────────────────────────────────
const _avatarCache = new Map(); // url → url (cache simple pour éviter les re-renders)

function cachedAvatar(url) {
    if (!url) return Promise.resolve(url);
    if (!_avatarCache.has(url)) _avatarCache.set(url, url);
    return Promise.resolve(_avatarCache.get(url));
}

// ─────────────────────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────────────────────
function addStyle() {
    const style = document.createElement('style');
    style.textContent = `
        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');

        :root {
            --jvc-bg:          #18181b;
            --jvc-surface-1:   #1f1f23;
            --jvc-surface-2:   #27272a;
            --jvc-surface-3:   #303035;
            --jvc-border:      #2e2e33;
            --jvc-divider:     #242428;
            --jvc-text:        #e4e4e7;
            --jvc-text-dim:    #a1a1aa;
            --jvc-text-muted:  #71717a;
            --jvc-orange:      #e84d1a;
            --jvc-orange-deep: #c73f13;
            --jvc-green:       #4ade80;
            --jvc-blue:        #7fb4ff;
            --jvc-red:         #f87171;
            --jvc-header-h:    52px;
            --jvc-safe-bottom: env(safe-area-inset-bottom, 0px);
        }

        *, *::before, *::after { box-sizing: border-box; }

        html, body {
            margin: 0; padding: 0;
            overflow: hidden;
            background: var(--jvc-bg);
            height: 100%;
            overscroll-behavior: none;
        }

        body * { font-family: 'Inter', system-ui, sans-serif; }

        /* ── LAYOUT ── */
        #jvcm-app {
            display: flex;
            flex-direction: column;
            height: 100vh; height: -webkit-fill-available; height: 100dvh;
            background: var(--jvc-bg);
            overflow: hidden;
            position: relative;
        }

        /* ── HEADER ── */
        #jvcm-header {
            flex: 0 0 auto;
            min-height: var(--jvc-header-h);
            display: flex;
            align-items: center;
            gap: 0.6rem;
            padding: 0 0.9rem;
            background: var(--jvc-surface-1);
            border-bottom: 1px solid var(--jvc-border);
            z-index: 10;
        }

        #jvcm-topic-title {
            flex: 1;
            font-size: 0.82rem; font-weight: 600;
            color: var(--jvc-text);
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
            cursor: pointer;
        }
        #jvcm-topic-title:active { opacity: 0.7; }

        #jvcm-meta { display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0; }

        .jvcm-chip {
            display: inline-flex; align-items: center; gap: 0.3rem;
            padding: 0.2rem 0.5rem;
            background: var(--jvc-surface-2); border: 1px solid var(--jvc-border);
            border-radius: 4px; font-size: 0.65rem; font-weight: 500;
            color: var(--jvc-text-dim); font-variant-numeric: tabular-nums; white-space: nowrap;
        }
        .jvcm-chip--live::before {
            content: ''; width: 5px; height: 5px; border-radius: 50%;
            background: var(--jvc-green); box-shadow: 0 0 4px var(--jvc-green);
        }

        #jvcm-home-btn {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.3rem;
            line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
        }
        #jvcm-home-btn:active { color: var(--jvc-text); }

        #jvcm-settings-btn {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1.1rem; cursor: pointer; padding: 0.2rem 0.3rem;
            line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
        }
        #jvcm-settings-btn:active { color: var(--jvc-text); }

        /* ── LOADING ── */
        #jvcm-loading {
            position: absolute; inset: var(--jvc-header-h) 0 0 0;
            display: flex; flex-direction: column; align-items: center; justify-content: center;
            gap: 0.75rem; background: var(--jvc-bg); z-index: 50;
        }
        #jvcm-loading.hidden { display: none; }
        .jvcm-spinner {
            width: 28px; height: 28px;
            border: 3px solid var(--jvc-surface-3);
            border-top-color: var(--jvc-orange);
            border-radius: 50%; animation: jvcm-spin 0.7s linear infinite;
        }
        @keyframes jvcm-spin { to { transform: rotate(360deg); } }
        .jvcm-loading-label { font-size: 0.75rem; color: var(--jvc-text-muted); }

        /* ── MESSAGES ── */
        #jvcm-messages {
            flex: 1; overflow-y: auto; overflow-x: hidden;
            overscroll-behavior: contain; display: flex; flex-direction: column;
            padding: 0.4rem 0 0.5rem; -webkit-overflow-scrolling: touch; min-height: 0;
        }
        #jvcm-messages::-webkit-scrollbar { width: 3px; }
        #jvcm-messages::-webkit-scrollbar-track { background: transparent; }
        #jvcm-messages::-webkit-scrollbar-thumb { background: var(--jvc-surface-3); border-radius: 2px; }

        /* ── SÉPARATEUR NOUVEAUX MESSAGES ── */
        .jvcm-new-sep {
            flex: 0 0 auto;
            display: flex; align-items: center; justify-content: center;
            padding: 0.45rem 1rem; font-size: 0.68rem; font-weight: 700;
            color: #fff; background: var(--jvc-orange);
            letter-spacing: 0.06em; text-transform: uppercase;
            cursor: pointer; border: none; width: 100%;
            -webkit-tap-highlight-color: transparent;
        }
        .jvcm-new-sep:active { opacity: 0.85; }
        .jvcm-new-sep::before, .jvcm-new-sep::after { display: none; }

        .jvcm-message {
            display: flex; gap: 0.6rem; padding: 0.5rem 0.75rem;
            border-bottom: 1px solid var(--jvc-divider); position: relative;
            transition: background 120ms ease, transform 80ms ease;
            -webkit-tap-highlight-color: transparent; user-select: none;
        }
        .jvcm-message:nth-child(even) { background: rgba(255,255,255,0.016); }
        .jvcm-message.is-pressing     { background: rgba(232,77,26,0.08); }
        .jvcm-message.mentions-me     { background: rgba(232,77,26,0.06) !important; border-left: 2px solid var(--jvc-orange); }

        /* Grouped : masquer seulement le pseudo */
        .jvcm-message.is-grouped { padding-top: 0.1rem; padding-bottom: 0.1rem; border-bottom: none; }
        .jvcm-message.is-grouped .jvcm-username { display: none; }
        .jvcm-message.is-grouped .jvcm-avatar { opacity: 0.5; }

        /* Swipe hint */
        .jvcm-swipe-hint {
            position: absolute; left: 0.5rem; top: 50%;
            transform: translateY(-50%) scale(0.6); opacity: 0; font-size: 1rem;
            pointer-events: none; transition: opacity 120ms ease, transform 120ms ease;
        }
        .jvcm-message.swipe-ready .jvcm-swipe-hint { opacity: 1; transform: translateY(-50%) scale(1); }

        .jvcm-avatar {
            width: 34px; height: 34px; border-radius: 50%;
            border: 1px solid var(--jvc-border); object-fit: cover; flex-shrink: 0; margin-top: 2px;
        }

        .jvcm-msg-body { flex: 1; min-width: 0; }

        .jvcm-msg-header {
            display: flex; align-items: baseline; gap: 0.45rem; margin-bottom: 0.15rem;
        }

        .jvcm-username { font-size: 0.82rem; font-weight: 700; color: oklch(72% 0.16 var(--pseudo-h, 220)); }
        .jvcm-userrank { font-size: 0.58rem; color: var(--jvc-text-muted); font-weight: 400; margin-left: 0.25rem; opacity: 0.8; }

        .jvcm-date { font-size: 0.62rem; color: var(--jvc-text-muted); font-variant-numeric: tabular-nums; cursor: default; }
        .jvcm-date .jvcm-date-abs { display: none; }
        .jvcm-date:hover .jvcm-date-rel, .jvcm-date.show-abs .jvcm-date-rel { display: none; }
        .jvcm-date:hover .jvcm-date-abs, .jvcm-date.show-abs .jvcm-date-abs { display: inline; }

        .jvcm-msg-status { font-size: 0.62rem; margin-left: 0.2rem; }
        .jvcm-msg-status.pending { color: var(--jvc-text-muted); }
        .jvcm-msg-status.ok      { color: var(--jvc-green); }
        .jvcm-msg-status.fail    { color: var(--jvc-red); cursor: pointer; }

        .jvcm-content { font-size: 0.88rem; line-height: 1.5; color: var(--jvc-text); word-break: break-word; overflow-wrap: anywhere; }
        .jvcm-content p { margin: 0 0 0.2rem; }
        .jvcm-content p:last-child { margin-bottom: 0; }
        .jvcm-content a { color: var(--jvc-blue); text-decoration: none; }

        .jvcm-content blockquote, .jvcm-content .message__blockquote {
            margin: 0.3rem 0 0.4rem; padding: 0.35rem 0.6rem;
            background: var(--jvc-surface-2); border-left: 3px solid var(--jvc-border);
            border-radius: 0 3px 3px 0; font-size: 0.78rem; color: var(--jvc-text-dim);
            cursor: pointer; transition: border-color 150ms ease, background 150ms ease;
        }
        .jvcm-content blockquote:hover, .jvcm-content .message__blockquote:hover {
            border-left-color: var(--jvc-orange); background: var(--jvc-surface-3);
        }
        .jvcm-content blockquote p { margin: 0; }

        .jvcm-content .message__blockquote blockquote > *:not(.jvcm-collapse-btn) { display: none; }
        .jvcm-content .message__blockquote.is-open blockquote > *:not(.jvcm-collapse-btn) { display: revert; }
        .jvcm-content .message__blockquote.is-open blockquote > .jvcm-collapse-btn { display: none; }

        .jvcm-collapse-btn {
            display: inline-flex; align-items: center; padding: 0.2rem 0.5rem;
            background: transparent; border: 1px dashed var(--jvc-border);
            border-radius: 999px; color: var(--jvc-text-muted);
            font-size: 0.65rem; font-family: inherit; cursor: pointer; margin: 0.1rem 0;
        }

        @keyframes jvcm-flash { 0%,100%{background:transparent} 30%{background:rgba(232,77,26,0.22)} }
        .jvcm-message.quote-flash { animation: jvcm-flash 900ms ease; }

        /* Animation d'entrée pour les nouveaux messages */
        @keyframes jvcm-msg-in {
            from { opacity: 0; transform: translateY(6px); }
            to   { opacity: 1; transform: translateY(0); }
        }
        .jvcm-message.is-new { animation: jvcm-msg-in 250ms ease; }

        .jvcm-content img { max-width: 100%; height: auto; border-radius: 3px; }

        .jvcm-message.is-deleted .jvcm-msg-body { opacity: 0.25; filter: grayscale(1); }
        .jvcm-message.is-deleted::after {
            content: 'Message supprimé'; position: absolute; top: 50%; left: 50%;
            transform: translate(-50%,-50%); font-size: 0.72rem; color: var(--jvc-text-muted);
            font-weight: 600; pointer-events: none;
        }

        /* ── SCROLL BUTTON ── */

        /* ── INPUT ── */
        #jvcm-input-wrap {
            flex: 0 0 auto; display: flex; flex-direction: column;
            background: var(--jvc-surface-1); border-top: 1px solid var(--jvc-border); z-index: 10;
        }

        /* Compteur de caractères */
        #jvcm-char-count {
            font-size: 0.62rem; font-variant-numeric: tabular-nums;
            text-align: right; padding: 0.2rem 0.75rem 0;
            color: var(--jvc-text-muted); display: none;
        }
        #jvcm-char-count.warn   { color: #fbbf24; display: block; }
        #jvcm-char-count.danger { color: var(--jvc-red); display: block; font-weight: 600; }

        #jvcm-input-bar {
            display: flex; align-items: flex-end; gap: 0.5rem;
            padding: 0.5rem 0.75rem; padding-bottom: calc(0.5rem + var(--jvc-safe-bottom));
        }

        #jvcm-textarea {
            flex: 1; min-height: 36px; max-height: 120px;
            padding: 0.45rem 0.7rem; background: var(--jvc-surface-2);
            border: 1px solid var(--jvc-border); border-radius: 18px;
            color: var(--jvc-text); font-size: 0.9rem; font-family: inherit;
            line-height: 1.4; resize: none; overflow-y: auto; outline: none;
            -webkit-appearance: none; transition: border-color 150ms ease;
        }
        #jvcm-textarea:focus { border-color: var(--jvc-orange); }
        #jvcm-textarea::placeholder { color: var(--jvc-text-muted); }

        #jvcm-send-btn {
            width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center;
            background: var(--jvc-orange); border: none; border-radius: 50%; cursor: pointer;
            -webkit-tap-highlight-color: transparent; transition: background 120ms ease, transform 100ms ease;
        }
        #jvcm-send-btn:active   { background: var(--jvc-orange-deep); transform: scale(0.92); }
        #jvcm-send-btn:disabled { opacity: 0.5; pointer-events: none; }
        #jvcm-send-btn svg { width: 16px; height: 16px; fill: #fff; }

        #jvcm-clear-btn {
            width: 36px; height: 36px; flex-shrink: 0;
            display: none; place-items: center;
            background: var(--jvc-surface-3); border: 1px solid var(--jvc-border);
            border-radius: 50%; cursor: pointer; color: var(--jvc-text-muted);
            font-size: 1rem; line-height: 1;
            -webkit-tap-highlight-color: transparent;
            transition: background 120ms ease, color 120ms ease;
        }
        #jvcm-clear-btn.visible { display: grid; }
        #jvcm-clear-btn:active { background: var(--jvc-surface-2); color: var(--jvc-text); }

        /* ── ACTIONS INLINE ── */
        .jvcm-actions { display: none; flex-direction: column; align-items: flex-end; gap: 0.2rem; margin-left: auto; flex-shrink: 0; }
        .jvcm-message.is-active .jvcm-actions { display: flex; }
        .jvcm-actions-row { display: flex; align-items: center; gap: 0.25rem; }

        .jvcm-action-btn {
            display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.6rem;
            background: var(--jvc-surface-2); border: 1px solid var(--jvc-border);
            border-radius: 4px; color: var(--jvc-text-dim); font-size: 0.72rem;
            font-weight: 500; font-family: inherit; cursor: pointer;
            -webkit-tap-highlight-color: transparent; transition: background 100ms ease, color 100ms ease;
        }
        .jvcm-action-btn:active { background: var(--jvc-surface-3); color: var(--jvc-text); }
        .jvcm-action-btn.danger { color: var(--jvc-red); border-color: rgba(248,113,113,0.3); }
        .jvcm-action-btn.danger:active { background: rgba(127,29,29,0.4); }

        /* ── TOAST ── */
        .jvcm-toast {
            position: fixed; top: calc(var(--jvc-header-h) + 8px); left: 50%;
            transform: translateX(-50%); z-index: 99999;
            padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.8rem;
            font-weight: 500; color: #fff; background: #1a1a1f;
            border: 1px solid var(--jvc-border); box-shadow: 0 4px 20px rgba(0,0,0,0.5);
            white-space: nowrap; animation: jvcm-toast-in 200ms ease; pointer-events: none;
        }
        .jvcm-toast.error   { background: #7f1d1d; border-color: var(--jvc-red); }
        .jvcm-toast.success { background: #14532d; border-color: var(--jvc-green); }
        .jvcm-toast.info    { background: #1e3a5f; border-color: var(--jvc-blue); }
        .jvcm-toast.warn    { background: #78350f; border-color: #fbbf24; }
        @keyframes jvcm-toast-in {
            from { opacity: 0; transform: translate(-50%, -8px); }
            to   { opacity: 1; transform: translate(-50%, 0); }
        }

        /* ── MODAL GÉNÉRIQUE (report + confirm delete) ── */
        .jvcm-modal-overlay {
            position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000;
            display: flex; align-items: center; justify-content: center; padding: 1rem;
        }
        .jvcm-modal-overlay.hidden { display: none; }

        .jvcm-modal {
            background: var(--jvc-surface-1); border: 1px solid var(--jvc-border);
            border-radius: 8px; width: 100%; max-width: 360px;
            display: flex; flex-direction: column; overflow: hidden;
        }
        .jvcm-modal-header {
            display: flex; align-items: center; justify-content: space-between;
            padding: 0.875rem 1rem; border-bottom: 1px solid var(--jvc-border);
            font-size: 0.9rem; font-weight: 600; color: var(--jvc-text);
        }
        .jvcm-modal-close {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1.3rem; cursor: pointer; line-height: 1; padding: 0.2rem;
        }
        .jvcm-modal-body { padding: 1rem; font-size: 0.85rem; color: var(--jvc-text-dim); line-height: 1.5; }
        .jvcm-modal-body strong { color: var(--jvc-text); }
        .jvcm-modal-footer {
            padding: 0.75rem 1rem; border-top: 1px solid var(--jvc-border);
            display: flex; gap: 0.5rem; justify-content: flex-end;
        }
        .jvcm-modal-cancel {
            padding: 0.45rem 1rem; background: var(--jvc-surface-2);
            border: 1px solid var(--jvc-border); border-radius: 5px;
            color: var(--jvc-text-dim); font-family: inherit; font-size: 0.82rem; cursor: pointer;
        }
        .jvcm-modal-confirm {
            padding: 0.45rem 1rem; background: var(--jvc-orange); border: none;
            border-radius: 5px; color: #fff; font-family: inherit;
            font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: background 150ms ease;
        }
        .jvcm-modal-confirm:hover { background: var(--jvc-orange-deep); }
        .jvcm-modal-confirm.danger { background: #991b1b; }
        .jvcm-modal-confirm.danger:hover { background: #7f1d1d; }

        /* ── SETTINGS ── */
        #jvcm-settings {
            position: fixed; inset: 0; background: var(--jvc-bg); z-index: 200;
            display: flex; flex-direction: column;
            transform: translateY(100%);
            transition: transform 300ms cubic-bezier(.4,0,.2,1);
            touch-action: pan-y;
        }
        #jvcm-settings.open { transform: translateY(0); }

        #jvcm-settings-header {
            display: flex; align-items: center; padding: 0 1rem;
            min-height: var(--jvc-header-h);
            background: var(--jvc-surface-1); border-bottom: 1px solid var(--jvc-border);
            gap: 0.75rem; font-size: 0.9rem; font-weight: 600; color: var(--jvc-text);
            cursor: grab;
        }
        /* Poignée de drag sur le settings panel */
        #jvcm-settings-header::before {
            content: ''; display: block; width: 32px; height: 4px;
            background: var(--jvc-border); border-radius: 2px;
            position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
        }
        #jvcm-settings-close {
            margin-left: auto; background: none; border: none;
            color: var(--jvc-text-muted); font-size: 1.4rem;
            cursor: pointer; line-height: 1; padding: 0.2rem 0.4rem;
        }
        #jvcm-settings-body {
            flex: 1; overflow-y: auto; padding: 1rem;
            display: flex; flex-direction: column; gap: 0.75rem;
        }
        .jvcm-setting-row {
            display: flex; align-items: center; justify-content: space-between;
            padding: 0.75rem 0.875rem; background: var(--jvc-surface-1);
            border: 1px solid var(--jvc-border); border-radius: 6px; gap: 1rem;
        }
        .jvcm-setting-label { font-size: 0.85rem; color: var(--jvc-text); }
        .jvcm-setting-desc  { font-size: 0.7rem; color: var(--jvc-text-muted); margin-top: 0.2rem; }

        .jvcm-toggle { position: relative; width: 40px; height: 22px; flex-shrink: 0; }
        .jvcm-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
        .jvcm-toggle-track {
            position: absolute; inset: 0; background: var(--jvc-surface-3);
            border-radius: 999px; border: 1px solid var(--jvc-border);
            transition: background 200ms ease; cursor: pointer;
        }
        .jvcm-toggle-track::after {
            content: ''; position: absolute; top: 2px; left: 2px;
            width: 16px; height: 16px; background: var(--jvc-text-muted);
            border-radius: 50%; transition: transform 200ms ease, background 200ms ease;
        }
        .jvcm-toggle input:checked + .jvcm-toggle-track { background: var(--jvc-orange); border-color: var(--jvc-orange); }
        .jvcm-toggle input:checked + .jvcm-toggle-track::after { transform: translateX(18px); background: #fff; }

        /* ── MODALE MATCHS ── */
        #jvcm-matches-overlay {
            position: fixed; inset: 0;
            background: rgba(0,0,0,0.75); z-index: 500;
            display: flex; align-items: flex-end; justify-content: center;
            padding: 0;
        }
        #jvcm-matches-overlay.hidden { display: none; }

        #jvcm-matches-panel {
            background: var(--jvc-bg);
            border: 1px solid var(--jvc-border);
            border-radius: 12px 12px 0 0;
            width: 100%; max-width: 520px;
            max-height: 85dvh;
            display: flex; flex-direction: column;
            overflow: hidden;
        }

        #jvcm-matches-handle {
            width: 36px; height: 4px;
            background: var(--jvc-border);
            border-radius: 2px;
            margin: 10px auto 0;
            flex-shrink: 0;
        }

        #jvcm-matches-header {
            display: flex; align-items: center;
            padding: 0.75rem 1rem 0.5rem;
            gap: 0.5rem; flex-shrink: 0;
        }
        #jvcm-matches-title {
            font-size: 0.9rem; font-weight: 700; color: var(--jvc-text); flex: 1;
        }
        #jvcm-matches-close {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1.3rem; cursor: pointer; line-height: 1; padding: 0.2rem;
        }
        #jvcm-matches-refresh {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1rem; cursor: pointer; padding: 0.2rem 0.4rem;
            border-radius: 4px; transition: color 150ms ease;
        }
        #jvcm-matches-refresh:active { color: var(--jvc-orange); }
        #jvcm-matches-refresh.spinning { animation: jvcm-spin 0.7s linear infinite; color: var(--jvc-orange); }

        /* Sélection ligues */
        #jvcm-leagues-wrap {
            padding: 0 1rem 0.5rem;
            display: flex; flex-wrap: wrap; gap: 0.35rem;
            flex-shrink: 0;
            border-bottom: 1px solid var(--jvc-border);
            padding-bottom: 0.6rem;
        }
        .jvcm-league-btn {
            padding: 0.25rem 0.6rem;
            background: var(--jvc-surface-2);
            border: 1px solid var(--jvc-border);
            border-radius: 999px;
            color: var(--jvc-text-muted);
            font-size: 0.68rem; font-weight: 600;
            font-family: inherit; cursor: pointer;
            transition: all 150ms ease;
            -webkit-tap-highlight-color: transparent;
        }
        .jvcm-league-btn.active {
            background: var(--jvc-orange);
            border-color: var(--jvc-orange);
            color: #fff;
        }

        /* Liste des matchs */
        #jvcm-matches-list {
            flex: 1; overflow-y: auto; padding: 0.5rem 0;
        }

        .jvcm-match-row { padding: 0.55rem 1rem; border-bottom: 1px solid var(--jvc-divider); display: flex; flex-direction: column; gap: 0.25rem; }
        .jvcm-match-row:last-child { border-bottom: none; }
        .jvcm-match-row[data-state="inProgress"] {
            background: rgba(239, 68, 68, 0.04);
            border-left: 2px solid rgba(239, 68, 68, 0.5);
        }

        /* Score game par game */
        .jvcm-match-games {
            display: flex; align-items: center; gap: 0.25rem;
            margin-top: 0.15rem; flex-wrap: wrap;
        }
        .jvcm-game-dot {
            width: 22px; height: 22px;
            border-radius: 4px;
            font-size: 0.62rem; font-weight: 700;
            display: grid; place-items: center;
            background: var(--jvc-surface-3);
            color: var(--jvc-text-muted);
            cursor: default;
            transition: opacity 150ms ease;
        }
        .jvcm-game-dot.clickable {
            cursor: pointer;
            border: 1px solid var(--jvc-border);
        }
        .jvcm-game-dot.clickable:active { opacity: 0.7; }
        .jvcm-game-dot.selected {
            border-color: var(--jvc-orange);
            box-shadow: 0 0 0 1px var(--jvc-orange);
        }
        .jvcm-game-dot.win  { background: rgba(74,222,128,0.2); color: var(--jvc-green); }
        .jvcm-game-dot.loss { background: rgba(248,113,113,0.15); color: var(--jvc-red); }
        .jvcm-game-dot.win-blue { background: rgba(100,149,237,0.25); color: #7fb4ff; border-color: rgba(100,149,237,0.4); }
        .jvcm-game-dot.win-red  { background: rgba(248,113,113,0.2); color: var(--jvc-red); border-color: rgba(248,113,113,0.4); }
        .jvcm-game-dot.live { background: rgba(239,68,68,0.2); color: #ef4444; animation: jvcm-breathe 2.4s ease-in-out infinite; }

        .jvcm-match-row-head {
            display: flex; align-items: center; gap: 0.4rem;
            font-size: 0.58rem; font-weight: 700;
            letter-spacing: 0.08em; text-transform: uppercase;
        }
        .jvcm-match-badge-live     { color: #ef4444; display: flex; align-items: center; gap: 0.3rem; }
        .jvcm-match-badge-upcoming { color: #fbbf24; text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 0.65rem; }
        .jvcm-match-badge-done     { color: var(--jvc-text-muted); }
        .jvcm-match-dot {
            width: 6px; height: 6px; border-radius: 50%;
            background: currentColor;
            animation: jvcm-breathe 2.4s ease-in-out infinite;
        }
        @keyframes jvcm-breathe {
            0%, 100% { opacity: 1; }
            50%       { opacity: 0.3; }
        }
        .jvcm-match-stage {
            color: var(--jvc-text-muted); font-weight: 500;
            font-size: 0.6rem; text-transform: none; letter-spacing: 0;
            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
        }
        .jvcm-match-row-body {
            display: flex; align-items: baseline;
            gap: 0.3rem; font-size: 0.82rem;
            font-weight: 700; color: var(--jvc-text);
        }
        .jvcm-match-team {
            flex: 1; min-width: 0; overflow: hidden;
            text-overflow: ellipsis; white-space: nowrap;
        }
        .jvcm-match-team:last-child { text-align: right; }
        .jvcm-match-team.winner { color: var(--jvc-green); }
        .jvcm-match-team.loser  { color: var(--jvc-text-muted); }
        .jvcm-match-score {
            flex-shrink: 0; font-size: 0.78rem;
            font-weight: 800; padding: 0 0.4rem;
            font-variant-numeric: tabular-nums;
        }
        .jvcm-match-vs {
            flex-shrink: 0; font-size: 0.65rem;
            color: var(--jvc-text-muted); padding: 0 0.4rem;
        }

        #jvcm-matches-empty {
            text-align: center; padding: 2rem 1rem;
            color: var(--jvc-text-muted); font-size: 0.82rem;
        }
        #jvcm-matches-loading {
            text-align: center; padding: 2rem 1rem;
            color: var(--jvc-text-muted); font-size: 0.82rem;
            display: flex; flex-direction: column; align-items: center; gap: 0.75rem;
        }

        /* Stats game terminée (expansion au clic sur dot) */
        .jvcm-game-stats {
            display: none; flex-direction: column;
            margin-top: 0.35rem;
            background: rgba(255,255,255,0.03);
            border: 1px solid var(--jvc-border);
            border-radius: 6px; overflow: hidden;
        }
        .jvcm-game-stats.open { display: flex; }
        .jvcm-game-stats-header {
            padding: 0.3rem 0.5rem;
            font-size: 0.6rem; font-weight: 700;
            color: var(--jvc-text-muted);
            text-transform: uppercase; letter-spacing: 0.08em;
            border-bottom: 1px solid var(--jvc-border);
            display: flex; align-items: center; gap: 0.4rem;
        }
        .jvcm-game-stats-loading {
            padding: 0.6rem; text-align: center;
            font-size: 0.65rem; color: var(--jvc-text-muted);
        }

        /* Détail par lane */
        .jvcm-lane-detail {
            display: none; flex-direction: column;
            border-top: 1px solid rgba(239,68,68,0.15);
        }
        .jvcm-lane-detail.open { display: flex; }
        .jvcm-lane-row {
            display: flex; align-items: center;
            padding: 0.35rem 0.5rem; gap: 0.3rem;
            border-bottom: 1px solid var(--jvc-divider);
            font-size: 0.62rem; font-variant-numeric: tabular-nums;
        }
        .jvcm-lane-row:last-child { border-bottom: none; }
        .jvcm-lane-badge {
            width: 20px; text-align: center; flex-shrink: 0;
            font-size: 0.6rem; color: var(--jvc-text-muted); font-weight: 600;
        }
        .jvcm-lane-player {
            flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem;
        }
        .jvcm-lane-player.right { align-items: flex-end; text-align: right; }
        .jvcm-lane-name     { font-weight: 600; color: var(--jvc-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80px; }
        .jvcm-lane-champ    { color: var(--jvc-text-muted); font-size: 0.58rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80px; }
        .jvcm-lane-kda      { color: var(--jvc-text-dim); font-size: 0.6rem; white-space: nowrap; }
        .jvcm-lane-gold     { color: #fbbf24; font-size: 0.6rem; font-weight: 600; }
        .jvcm-lane-mid-col  { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; flex-shrink: 0; padding: 0 0.2rem; }
        .jvcm-lane-cs       { color: var(--jvc-text-muted); font-size: 0.58rem; font-style: italic; }
        .jvcm-lane-level    { font-size: 0.6rem; color: var(--jvc-text-muted); background: var(--jvc-surface-3); border-radius: 3px; padding: 0.05rem 0.25rem; }

        /* Streams */
        .jvcm-match-streams {
            display: flex; align-items: center; gap: 0.35rem;
            margin-top: 0.4rem; flex-wrap: wrap;
        }
        .jvcm-stream-btn {
            display: inline-flex; align-items: center; gap: 0.3rem;
            padding: 0.28rem 0.65rem;
            background: #9147ff; border: none; border-radius: 4px;
            color: #fff; font-size: 0.7rem; font-weight: 600;
            font-family: inherit; cursor: pointer;
            -webkit-tap-highlight-color: transparent;
            transition: background 120ms ease;
            text-decoration: none;
        }
        .jvcm-stream-btn:active { background: #7b35d9; }
        .jvcm-stream-select {
            padding: 0.28rem 0.5rem;
            background: #9147ff; border: none; border-radius: 4px;
            color: #fff; font-size: 0.7rem; font-weight: 600;
            font-family: inherit; cursor: pointer;
            -webkit-appearance: none; outline: none;
        }

        /* Fenêtre temporelle */
        #jvcm-time-range {
            padding: 0.5rem 1rem 0.4rem;
            border-bottom: 1px solid var(--jvc-border);
            display: flex; gap: 1rem; flex-shrink: 0;
        }
        .jvcm-range-group {
            flex: 1; display: flex; flex-direction: column; gap: 0.25rem;
        }
        .jvcm-range-label {
            font-size: 0.6rem; font-weight: 600; color: var(--jvc-text-muted);
            text-transform: uppercase; letter-spacing: 0.08em;
            display: flex; justify-content: space-between;
        }
        .jvcm-range-label span { color: var(--jvc-text); font-variant-numeric: tabular-nums; }
        .jvcm-range-input {
            -webkit-appearance: none; appearance: none;
            width: 100%; height: 4px;
            background: var(--jvc-surface-3); border-radius: 2px; outline: none;
        }
        .jvcm-range-input::-webkit-slider-thumb {
            -webkit-appearance: none; width: 16px; height: 16px;
            border-radius: 50%; background: var(--jvc-orange); cursor: pointer;
        }
        .jvcm-range-input::-moz-range-thumb {
            width: 16px; height: 16px; border: none;
            border-radius: 50%; background: var(--jvc-orange); cursor: pointer;
        }

        /* Bouton matchs dans le header */
        #jvcm-matches-btn {
            background: none; border: none; color: var(--jvc-text-muted);
            font-size: 1rem; cursor: pointer; padding: 0.2rem 0.3rem;
            line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
            position: relative;
        }
        #jvcm-matches-btn:active { color: var(--jvc-orange); }
        .jvcm-live-badge {
            position: absolute; top: -2px; right: -2px;
            min-width: 14px; height: 14px; padding: 0 3px;
            background: #ef4444; color: #fff;
            border-radius: 999px; font-size: 0.5rem; font-weight: 700;
            display: flex; align-items: center; justify-content: center;
            pointer-events: none;
        }
    `;
    document.head.appendChild(style);
}

// ─────────────────────────────────────────────────────────────
// SETTINGS
// ─────────────────────────────────────────────────────────────
class Settings {
    constructor() {
        this._defs = [
            { key: 'send_on_enter',  label: 'Envoyer avec Entrée',     desc: 'Sinon, Entrée = saut de ligne',                      default: false },
            { key: 'swipe_to_quote', label: 'Swipe droite pour citer', desc: 'Glisser un message vers la droite',                  default: true  },
            { key: 'notifications',  label: 'Notifications navigateur',desc: 'Alerte quand un nouveau message arrive (hors onglet)',default: false },
            { key: 'vibration',      label: 'Vibration sur mention',   desc: 'Vibre quand quelqu\'un te cite (Android uniquement)', default: true  },
        ];
    }
    get(key) {
        const raw = localStorage.getItem(`jvcm:setting:${key}`);
        const def = this._defs.find(d => d.key === key);
        try { return raw !== null ? JSON.parse(raw) : (def?.default ?? false); } catch { return def?.default ?? false; }
    }
    set(key, val) { try { localStorage.setItem(`jvcm:setting:${key}`, JSON.stringify(val)); } catch {} }
    get defs() { return this._defs; }
}

// ─────────────────────────────────────────────────────────────
// HORODATAGE RELATIF
// ─────────────────────────────────────────────────────────────
function parseJVCDate(str) {
    if (!str) return null;
    const short = str.match(/(\d{2}):(\d{2}):(\d{2})$/);
    if (short) {
        const now = new Date();
        return new Date(now.getFullYear(), now.getMonth(), now.getDate(), +short[1], +short[2], +short[3]).getTime();
    }
    const long = str.match(/(\d+)\s+(\w+)\s+(\d{4}).*?(\d{2}):(\d{2}):(\d{2})/);
    if (long) {
        const months = { janvier:0,février:1,mars:2,avril:3,mai:4,juin:5,juillet:6,août:7,septembre:8,octobre:9,novembre:10,décembre:11 };
        return new Date(+long[3], months[long[2].toLowerCase()] ?? 0, +long[1], +long[4], +long[5], +long[6]).getTime();
    }
    return null;
}

function relativeTime(ts) {
    if (!ts) return '';
    const s = Math.floor((Date.now() - ts) / 1000);
    if (s < 60)  return "à l'instant";
    const m = Math.floor(s / 60);
    if (m < 60)  return `il y a ${m} min`;
    const h = Math.floor(m / 60);
    if (h < 24)  return `il y a ${h}h`;
    return `il y a ${Math.floor(h / 24)}j`;
}

// ─────────────────────────────────────────────────────────────
// UTILITAIRES
// ─────────────────────────────────────────────────────────────
function pseudoHue(pseudo) {
    let h = 0;
    for (let i = 0; i < pseudo.length; i++) h = ((h << 5) - h + pseudo.charCodeAt(i)) | 0;
    return Math.abs(h) % 360;
}

function jvCake(str) {
    const base16 = '0A12B34C56D78E9F';
    const s = str.split(' ')[1];
    let lien = '';
    for (let i = 0; i < s.length; i += 2)
        lien += String.fromCharCode(base16.indexOf(s.charAt(i)) * 16 + base16.indexOf(s.charAt(i + 1)));
    return lien;
}

function reverseMessage(node, isInit, isUl) {
    let quote = '', prevIsP = false, startsWithSpoil = false;
    for (const child of node.childNodes) {
        const name = child.nodeName;
        switch (name) {
            case 'P':      quote += reverseMessage(child) + '\n\n'; break;
            case 'STRONG': quote += "'''" + reverseMessage(child) + "'''"; break;
            case 'EM':     quote += "''" + reverseMessage(child) + "''"; break;
            case 'U':      quote += '<u>' + reverseMessage(child) + '</u>'; break;
            case 'S':      quote += '<s>' + reverseMessage(child) + '</s>'; break;
            case 'BR':     quote += '\n'; break;
            case 'UL':     quote += reverseMessage(child, false, true) + '\n\n'; break;
            case 'OL':     quote += reverseMessage(child, false, false) + '\n\n'; break;
            case 'LI':     quote += (isUl ? '* ' : '# ') + reverseMessage(child) + '\n'; break;
            case 'IMG':    quote += child.alt; break;
            case 'A':      quote += child.href ? child.href : reverseMessage(child); break;
            case 'CODE':   quote += '<code>' + child.textContent + '</code>'; break;
            case 'PRE':    quote += reverseMessage(child) + '\n\n'; break;
            case 'BLOCKQUOTE':
                quote += (prevIsP ? quote.trimEnd() + '\n' : '') + reverseMessage(child).replace(/^/gm, '> ') + '\n\n';
                break;
            case 'DIV': case 'SPAN': {
                const cl = child.classList;
                if (cl.contains('message__spoil')) {
                    if (!quote) startsWithSpoil = true;
                    quote += '<spoil>' + reverseMessage(child) + '</spoil>\n\n';
                } else if (cl.contains('message__spoilContent')) {
                    quote += reverseMessage(child);
                }
                break;
            }
            case '#text':
                if (!isInit || child.textContent.trim() !== '') {
                    quote += child.textContent;
                    if (isInit && !quote.endsWith('\n')) quote += '\n';
                }
                break;
        }
        prevIsP = (name === 'P');
    }
    quote = quote.replace(/(\n){3,}/g, '\n\n');
    if (startsWithSpoil && isInit) quote = '\n' + quote.trimEnd();
    else quote = quote.trim();
    if (isInit) quote = quote.replace(/^/gm, '> ');
    return quote;
}

function updateFavicon(count) {
    const link = document.querySelector('link[rel*="icon"]') || (() => {
        const l = document.createElement('link'); l.rel = 'icon'; document.head.appendChild(l); return l;
    })();
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = canvas.height = 16;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, 16, 16);
        if (count > 0) {
            const label = count > 99 ? '99+' : String(count);
            ctx.fillStyle = '#e84d1a';
            ctx.fillRect(0, 0, ctx.measureText(label).width + 4, 11);
            ctx.fillStyle = '#fff';
            ctx.font = 'bold 9px sans-serif';
            ctx.textBaseline = 'bottom';
            ctx.fillText(label, 2, 11);
        }
        link.href = canvas.toDataURL();
    };
    img.onerror = () => {};
    img.src = link.href || '/favicon.ico';
}

// ─────────────────────────────────────────────────────────────
// NOTIFICATIONS
// ─────────────────────────────────────────────────────────────
async function requestNotifPermission() {
    if (!('Notification' in window)) return false;
    if (Notification.permission === 'granted') return true;
    if (Notification.permission === 'denied') return false;
    return (await Notification.requestPermission()) === 'granted';
}

function sendNotif(title, body) {
    if (!('Notification' in window) || Notification.permission !== 'granted') return;
    const n = new Notification(title, { body, icon: '/favicon.ico', tag: 'jvchat' });
    n.onclick = () => { window.focus(); n.close(); };
    setTimeout(() => n.close(), 6000);
}

// ─────────────────────────────────────────────────────────────
// PARSER
// ─────────────────────────────────────────────────────────────
function parsePage(doc) {
    const titleEl = doc.querySelector('#title-display-container');
    const title = titleEl ? titleEl.textContent.trim() : '';

    let lastPage = 1;
    for (const a of doc.querySelectorAll('.pagination .pagination__navigation a')) {
        const p = parseInt(a.textContent.trim());
        if (!isNaN(p) && p > lastPage) lastPage = p;
    }
    const cur = doc.querySelector('.pagination__item--current');
    if (cur) { const p = parseInt(cur.textContent); if (!isNaN(p) && p > lastPage) lastPage = p; }
    for (const a of doc.querySelectorAll('.bloc-liste-num-page span a')) {
        const p = parseInt(a.textContent.trim());
        if (!isNaN(p) && p > lastPage) lastPage = p;
    }

    const messages = [];
    for (const msg of doc.querySelectorAll('#listMessages .messageUser.js-hybrid-component, #listMessages .messageUser, .bloc-message-forum')) {
        if (msg.children.length === 0) continue;
        const msgEl   = msg.querySelector('.messageUser__msg, .bloc-contenu');
        const dateEl  = msg.querySelector('.messageUser__date, .bloc-date-msg');
        const labelEl = msg.querySelector('.messageUser__label, .bloc-pseudo-msg');
        if (!msgEl || !dateEl || !labelEl) continue;
        let avatarUrl = 'https://image.jeuxvideo.com/avatar/default.jpg';
        const av = msg.querySelector('img.avatar__image, .bloc-avatar img');
        if (av) avatarUrl = av.src;
        const username  = labelEl.textContent.trim();
        const rankEl    = msg.querySelector('.messageUser__memberGroup, .messageUser__rank, .bloc-grade-msg, .grade-pseudo');
        const userRank  = rankEl?.textContent.trim() || '';
        const id = msg.id ? msg.id.split('-').pop() : msg.getAttribute('data-id');
        messages.push({ id, page: lastPage, username, userRank, avatarUrl, content: msgEl.innerHTML, creationDate: dateEl.textContent.trim() });
    }

    const connectedEl = doc.querySelector('#forums-info-app .sideCardForum__headerExtra .sideCardForum__Link');
    const connectedCount = connectedEl ? parseInt(connectedEl.textContent.trim()) || 0 : 0;

    return { title, lastPage, messages, connectedCount };
}

function getPayload(doc) {
    for (const sc of doc.getElementsByTagName('script')) {
        const txt = sc.textContent || '';
        const m = txt.match(/(?:window\.)?jvc\.forumsAppPayload\s*=\s*['"]([^'"]+)['"]/);
        if (m) { try { return JSON.parse(atob(m[1])); } catch (e) { err('payload:', e); } }
    }
    return null;
}

// ─────────────────────────────────────────────────────────────
// API
// ─────────────────────────────────────────────────────────────
class JVCAPI {
    constructor(viewId, forumId, topicId, topicTitle) {
        Object.assign(this, { viewId, forumId, topicId, topicTitle, payload: null });
    }
    _url(page) {
        return `https://www.jeuxvideo.com/forums/${this.viewId}-${this.forumId}-${this.topicId}-${page}-0-1-0-${this.topicTitle}.htm`;
    }
    async getPageDocument(page) {
        const res = await fetch(this._url(page));
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return new DOMParser().parseFromString(await res.text(), 'text/html');
    }
    async getMessage(id) {
        try {
            const res = await fetch(`https://www.jeuxvideo.com/forums/message/${id}`);
            return res.ok ? await res.text() : null;
        } catch { return null; }
    }
    async postMessage(text) {
        const fd = new FormData();
        fd.set('text', text); fd.set('topicId', this.topicId); fd.set('forumId', this.forumId);
        fd.set('group', 1); fd.set('messageId', 'undefined'); fd.set('ajax_hash', this.payload.ajaxToken);
        for (const k in this.payload.formSession) fd.append(k, this.payload.formSession[k]);
        const res = await fetch('https://www.jeuxvideo.com/forums/message/add', {
            credentials: 'include', method: 'POST', mode: 'cors', body: fd
        });
        if (!res.ok) throw new Error(await res.text());
        const data = await res.json();
        if (data.errors) {
            const msgs = Object.values(data.errors);
            if (msgs.length) throw new Error(msgs.join(' | '));
        }
    }
    async deleteMessage(msgId) {
        const url = `https://www.jeuxvideo.com${this.payload.topicActions.deleteMessageUrl}&ids=${msgId}`;
        const res = await fetch(url, { credentials: 'include', method: 'POST', mode: 'cors' });
        const data = await res.json();
        if (data.errors?.length) throw new Error(data.errors.join(' | '));
    }
    async getEditForm(msgId) {
        const url = `https://www.jeuxvideo.com/forums/message/edit/form-values?id_message=${msgId}&ajax_hash=${this.payload.ajaxToken}`;
        const res = await fetch(url, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
        if (!res.ok) throw new Error('Echec récupération');
        const data = await res.json();
        if (data.errors) {
            const msgs = Array.isArray(data.errors) ? data.errors : Object.values(data.errors);
            if (msgs.length) throw new Error(msgs.join(' | '));
        }
        const jvcode = data.jvcode || data.text || '';
        if (!jvcode) throw new Error("Délai d'édition dépassé");
        const fs = {};
        for (const k in data) if (k.startsWith('fs_')) fs[k] = data[k];
        if (data.edit_form_session) Object.assign(fs, data.edit_form_session);
        return { jvcode, formSession: fs };
    }
    async updateMessage(msgId, text, formSession) {
        const fd = new FormData();
        fd.set('text', text); fd.set('topicId', this.topicId); fd.set('forumId', this.forumId);
        fd.set('group', 1); fd.set('messageId', msgId); fd.set('ajax_hash', this.payload.ajaxToken);
        fd.set('resetFormAfterSuccess', 'false');
        for (const k in formSession) fd.append(k, formSession[k]);
        const res = await fetch('https://www.jeuxvideo.com/forums/message/edit', {
            method: 'POST', credentials: 'include',
            headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' }, body: fd
        });
        const data = await res.json();
        if (data.errors) {
            const msgs = Object.values(data.errors);
            if (msgs.length) throw new Error(msgs.join(' | '));
        }
        return data.html;
    }
}

// ─────────────────────────────────────────────────────────────
// LONG PRESS
// ─────────────────────────────────────────────────────────────
function attachLongPress(el, onLongPress, delay = 420) {
    let timer = null, fired = false, active = false, startX, startY;
    const cancel = () => { clearTimeout(timer); timer = null; active = false; };

    el.addEventListener('touchstart', (e) => {
        fired = false; active = true;
        startX = e.touches[0].clientX; startY = e.touches[0].clientY;
        timer = setTimeout(() => { fired = true; onLongPress(e); }, delay);
    }, { passive: true });
    el.addEventListener('touchmove', (e) => {
        if (!active) return;
        if (Math.abs(e.touches[0].clientX - startX) > 8 || Math.abs(e.touches[0].clientY - startY) > 8) cancel();
    }, { passive: true });
    el.addEventListener('touchend',    cancel);
    el.addEventListener('touchcancel', cancel);

    el.addEventListener('mousedown', (e) => {
        if (e.button !== 0) return;
        fired = false; active = true;
        startX = e.clientX; startY = e.clientY;
        timer = setTimeout(() => { fired = true; onLongPress(e); }, delay);
        const onMove = (ev) => {
            if (Math.abs(ev.clientX - startX) > 8 || Math.abs(ev.clientY - startY) > 8) { cancel(); cleanup(); }
        };
        const onUp = () => { cancel(); cleanup(); };
        const cleanup = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup',   onUp);
    });
    el.addEventListener('contextmenu', (e) => { if (fired) { e.preventDefault(); fired = false; } });
}

// ─────────────────────────────────────────────────────────────
// MATCHS ESPORT
// ─────────────────────────────────────────────────────────────

const LOLESPORTS_API  = 'https://esports-api.lolesports.com/persisted/gw';
const LOLESPORTS_KEY  = '0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z';
const MATCHES_TTL     = 60_000;
const BO3GG_API       = 'https://api.bo3.gg/api/v1';
const BO3GG_CACHE_TTL = 12 * 3600 * 1000;
const BO3GG_BLOCK_KEY = 'jvcm:bo3_blocked_until';
const BO3GG_BLOCK_MS  = 30 * 60_000;

// Toutes les ligues disponibles avec leur label affiché
const ALL_LEAGUES = [
    // Tier 1
    { slug: 'lec',             label: 'LEC',         group: 'Tier 1' },
    { slug: 'lck',             label: 'LCK',         group: 'Tier 1' },
    { slug: 'lpl',             label: 'LPL',         group: 'Tier 1' },
    { slug: 'lcp',             label: 'LCP',         group: 'Tier 1' },
    { slug: 'lcs',             label: 'LCS',         group: 'Tier 1' },
    { slug: 'cblol',           label: 'CBLOL',       group: 'Tier 1' },
    { slug: 'worlds',          label: 'Worlds',      group: 'Tier 1' },
    { slug: 'msi',             label: 'MSI',         group: 'Tier 1' },
    { slug: 'first_stand',     label: 'First Stand', group: 'Tier 1' },
    { slug: 'ewc',             label: 'EWC',         group: 'Tier 1' },
    // Tier 2
    { slug: 'emea_masters',    label: 'EMEA Masters',group: 'Tier 2'   },
    // ERL
    { slug: 'lfl',             label: 'LFL',         group: 'Tier 2'    },
    { slug: 'primeleague',     label: 'Prime League',group: 'Tier 2'    },
    { slug: 'superliga',       label: 'Superliga',   group: 'Tier 2'    },
    { slug: 'nlc',             label: 'NLC',         group: 'Tier 2'    },
    { slug: 'pg_nationals',    label: 'PG Nationals',group: 'Tier 2'    },
    { slug: 'ultraliga',       label: 'Ultraliga',   group: 'Tier 2'    },
    { slug: 'tal',             label: 'TAL',         group: 'Tier 2'    },
];

const BO3GG_TOURNAMENTS = {
    ewc: [
        'esports-world-cup-2026-emea-qualifier',
        'esports-world-cup-2026-asia-pacific-qualifier',
        'esports-world-cup-2026-korea-qualifier',
        'esports-world-cup-2026',
    ],
};

const LEAGUES_STORAGE_KEY = 'jvcm:selected-leagues';
const LEAGUES_TTL = 7 * 24 * 3600_000; // 7 jours

function getSelectedLeagues() {
    try {
        const raw = localStorage.getItem(LEAGUES_STORAGE_KEY);
        if (raw) {
            const { slugs, ts } = JSON.parse(raw);
            if (Date.now() - ts < LEAGUES_TTL) return slugs;
            // Expiré : supprimer et retourner le défaut
            try { localStorage.removeItem(LEAGUES_STORAGE_KEY); } catch {}
        }
    } catch {}
    return ['lec', 'lck', 'lpl', 'lfl'];
}

function setSelectedLeagues(slugs) {
    try {
        localStorage.setItem(LEAGUES_STORAGE_KEY, JSON.stringify({ slugs, ts: Date.now() }));
    } catch {}
}

function gmFetch(url, headers = {}) {
    return new Promise((resolve, reject) => {
        const fn = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest
                 : (typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest
                 : null;
        // Fallback fetch natif (iOS Safari, contextes sans GM_xmlhttpRequest)
        if (!fn) {
            fetch(url, { headers })
                .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
                .then(resolve).catch(reject);
            return;
        }
        fn({
            method: 'GET', url, headers, timeout: 10_000,
            onload: r => {
                if (r.status >= 200 && r.status < 300) {
                    try { resolve(JSON.parse(r.responseText)); }
                    catch (e) { reject(new Error('JSON parse error')); }
                } else { reject(new Error(`HTTP ${r.status}`)); }
            },
            onerror:   () => reject(new Error('network error')),
            ontimeout: () => reject(new Error('timeout')),
        });
    });
}

function _matchEsc(s) {
    return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}

function _matchFormatStart(iso) {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    const diff = d.getTime() - Date.now();
    const hh = String(d.getHours()).padStart(2,'0');
    const mm = String(d.getMinutes()).padStart(2,'0');
    if (diff < 0) return `Imminent (${hh}:${mm})`;
    const mins = Math.round(diff / 60_000);
    if (mins < 60) return `Dans ${mins} min`;
    const h = Math.floor(mins / 60);
    const r = mins % 60;
    if (h < 24) return `Dans ${h}h${r > 0 ? String(r).padStart(2,'0') : ''}`;
    const today    = new Date(); today.setHours(0,0,0,0);
    const tomorrow = new Date(today.getTime() + 86_400_000);
    if (d >= today && d < tomorrow)    return `Aujourd'hui ${hh}:${mm}`;
    if (d >= tomorrow)                 return `Demain ${hh}:${mm}`;
    return d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}) + ` ${hh}:${mm}`;
}

function _matchFormatPast(iso) {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    const hh = String(d.getHours()).padStart(2,'0');
    const mm = String(d.getMinutes()).padStart(2,'0');
    const today = new Date(); today.setHours(0,0,0,0);
    const yesterday = new Date(today.getTime() - 86_400_000);
    if (d >= today)      return `Aujourd'hui ${hh}h${mm}`;
    if (d >= yesterday)  return `Hier ${hh}h${mm}`;
    return d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}) + ` ${hh}h${mm}`;
}

async function fetchLolesportsMatches(allowedSlugs, pastDays = 1, futureDays = 7) {
    const allowed  = new Set(allowedSlugs.map(s => s.toLowerCase()));
    const headers  = { 'x-api-key': LOLESPORTS_KEY };
    const now      = Date.now();
    // L'API getSchedule retourne max ~48h dans le passé — on plafonne à 2j
    const agoMs    = now - Math.min(pastDays, 2) * 24 * 3600_000;
    const futureMs = now + Math.min(futureDays, 7) * 24 * 3600_000;
    const isOk     = e => allowed.has((e.league?.slug || '').toLowerCase());

    const [liveData, schedData] = await Promise.all([
        gmFetch(`${LOLESPORTS_API}/getLive?hl=fr-FR`, headers).catch(() => null),
        gmFetch(`${LOLESPORTS_API}/getSchedule?hl=fr-FR`, headers).catch(() => null),
    ]);

    const liveEvents  = liveData?.data?.schedule?.events || [];
    const schedEvents = schedData?.data?.schedule?.events || [];

    const filtered = schedEvents.filter(e => {
        if (e.type !== 'match' || !isOk(e)) return false;
        const t = new Date(e.startTime).getTime();
        if (e.state === 'completed')  return t >= agoMs;
        if (e.state === 'inProgress') return true;
        if (e.state === 'unstarted')  return t <= futureMs;
        return false;
    });

    const seen = new Set(); const matches = [];
    for (const ev of [...liveEvents, ...filtered]) {
        const id = ev.match?.id;
        if (!id || seen.has(id) || !isOk(ev)) continue;
        seen.add(id);
        matches.push({
            matchId: id, startTime: ev.startTime, state: ev.state,
            blockName: ev.blockName || '',
            league: { name: ev.league?.name || '', slug: ev.league?.slug || '' },
            teams: (ev.match?.teams || []).map(t => ({
                name: t.name, code: t.code,
                wins: t.result?.gameWins || 0, outcome: t.result?.outcome || null,
            })),
            strategy: ev.match?.strategy || {},
            games: (ev.match?.games || []).map(g => ({
                number: g.number,
                state: g.state,
                teams: (g.teams || []).map(t => ({ id: t.id, side: t.side })),
            })),
            matchUrl: `https://lolesports.com/match/${id}`,
            streams: (ev.streams || [])
                .filter(s => s.provider === 'twitch')
                .map(s => s.parameter)
                .filter(Boolean),
            source: 'lolesports',
        });
    }
    return matches;
}

function _bo3CollectMatches(tournament) {
    const out = [];
    for (const stage of (tournament?.stages || [])) {
        for (const round of (stage?.rounds || [])) {
            for (const m of (round?.matches || [])) out.push({ ...m, _stage: stage, _round: round });
        }
    }
    return out;
}

function _bo3NormalizeMatch(m) {
    if (!m?.start_date) return null;
    const ts = new Date(m.start_date).getTime();
    if (!ts || isNaN(ts)) return null;
    const t1 = { name: m.team1?.name || 'TBD', code: (m.team1?.acronym?.trim()) || m.team1?.name || 'TBD' };
    const t2 = { name: m.team2?.name || 'TBD', code: (m.team2?.acronym?.trim()) || m.team2?.name || 'TBD' };
    const s1 = parseInt(m.team1_score, 10) || 0;
    const s2 = parseInt(m.team2_score, 10) || 0;
    let state, o1 = null, o2 = null;
    const status = (m.status || '').toLowerCase();
    if (status === 'finished' || status === 'defwin' || m.winner_team_id) {
        state = 'completed';
        if (m.winner_team_id === m.team1_id)      { o1='win'; o2='loss'; }
        else if (m.winner_team_id === m.team2_id) { o1='loss'; o2='win'; }
        else if (s1 > s2) { o1='win'; o2='loss'; }
        else if (s2 > s1) { o1='loss'; o2='win'; }
    } else if (['started','live','in_progress'].includes(status)) {
        state = 'inProgress';
    } else {
        state = 'unstarted';
    }
    return {
        matchId: `bo3-${m.id || ts}`, startTime: new Date(ts).toISOString(),
        state, blockName: m._stage?.title || m._round?.name || '',
        league: { name: 'EWC 2026', slug: 'ewc' },
        teams: [
            { name: t1.name, code: t1.code, wins: s1, outcome: o1 },
            { name: t2.name, code: t2.code, wins: s2, outcome: o2 },
        ],
        strategy: { count: parseInt(m.bo_type, 10) || 3 },
        source: 'bo3gg',
    };
}

async function fetchBo3ggTournament(slug) {
    const cacheKey = `jvcm:bo3:${slug}`;
    try {
        const raw = localStorage.getItem(cacheKey);
        if (raw) {
            const c = JSON.parse(raw);
            if (Date.now() - c.ts < BO3GG_CACHE_TTL) return c.matches;
        }
    } catch {}
    const data = await gmFetch(`${BO3GG_API}/tournaments/${encodeURIComponent(slug)}`);
    if (!data || data.error) throw new Error(data?.error?.message || 'bo3.gg error');
    const matches = _bo3CollectMatches(data).map(_bo3NormalizeMatch).filter(Boolean);
    try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), matches })); } catch {}
    return matches;
}

async function fetchBo3ggMatches(allowedSlugs) {
    const blockedUntil = parseInt(localStorage.getItem(BO3GG_BLOCK_KEY) || '0', 10);
    if (Date.now() < blockedUntil) return [];
    const slugs = new Set();
    for (const league of allowedSlugs) {
        if (BO3GG_TOURNAMENTS[league]) for (const s of BO3GG_TOURNAMENTS[league]) slugs.add(s);
    }
    if (!slugs.size) return [];
    const all = []; let hadError = false;
    for (const slug of slugs) {
        try { all.push(...await fetchBo3ggTournament(slug)); }
        catch (e) { err('bo3.gg:', e.message); hadError = true; }
    }
    if (hadError && !all.length) localStorage.setItem(BO3GG_BLOCK_KEY, String(Date.now() + BO3GG_BLOCK_MS));
    const now = Date.now();
    const min = now - 48 * 3600_000;
    const max = now + 7 * 24 * 3600_000;
    const seen = new Set();
    return all.filter(m => {
        const t = new Date(m.startTime).getTime();
        if (t < min || t > max) return false;
        const k = `${m.teams[0]?.code}-${m.teams[1]?.code}-${m.startTime}`;
        if (seen.has(k)) return false;
        seen.add(k); return true;
    });
}

async function fetchMatches(allowedSlugs, pastDays = 1, futureDays = 7) {
    const cacheKey = `jvcm:matches:${[...allowedSlugs].sort().join(',')}_p${pastDays}_f${futureDays}`;
    try {
        const raw = localStorage.getItem(cacheKey);
        if (raw) {
            const c = JSON.parse(raw);
            if (Date.now() - c.ts < MATCHES_TTL) return c.data;
        }
    } catch {}

    let matches = await fetchLolesportsMatches(allowedSlugs, pastDays, futureDays).catch(e => { err('lolesports:', e.message); return []; });

    const wantsExtra = allowedSlugs.some(l => BO3GG_TOURNAMENTS[l]);
    if (wantsExtra) {
        const extra = await fetchBo3ggMatches(allowedSlugs).catch(() => []);
        if (extra.length) {
            const keyOf = m => `${m.teams[0]?.code}-${m.teams[1]?.code}-${m.startTime}`;
            const seen = new Set(matches.map(keyOf));
            for (const m of extra) { const k = keyOf(m); if (!seen.has(k)) { matches.push(m); seen.add(k); } }
        }
    }

    const order = { inProgress: 0, unstarted: 1, completed: 2 };
    matches.sort((a, b) => {
        const d = (order[a.state] ?? 9) - (order[b.state] ?? 9);
        return d !== 0 ? d : new Date(a.startTime) - new Date(b.startTime);
    });

    try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: matches })); } catch {}
    return matches;
}

// Streams Twitch de fallback par ligue slug (quand l'API ne retourne rien)
const LEAGUE_STREAMS = {
    lec:          ['lec', 'otplol_', 'caedrel'],
    lck:          ['lck'],
    lpl:          ['lpl'],
    lcp:          ['lcp'],
    lfl:          ['otplol_', 'solary'],
    lcs:          ['lcsofficial'],
    cblol:        ['cblol'],
    emea_masters: ['emeamasters'],
    primeleague:  ['primeleague'],
    superliga:    ['lvpes'],
    nlc:          ['nlcofficial'],
    pg_nationals: ['pgnationals'],
    ultraliga:    ['ultraliga'],
    tal:          ['talofficial'],
    worlds:       ['lec', 'riotgames'],
    msi:          ['lec', 'riotgames'],
    first_stand:  ['lec'],
    ewc:          ['lec'],
};

function getStreamsForMatch(m) {
    // Priorité : streams déclarés par l'API, sinon fallback par ligue
    const declared = (m.streams || []).filter(Boolean);
    if (declared.length) return declared;
    return LEAGUE_STREAMS[m.league?.slug?.toLowerCase()] || [];
}

function openTwitch(channel) {
    // Tente d'ouvrir l'app Twitch native, fallback sur le navigateur
    const appUrl     = `twitch://stream/${channel}`;
    const browserUrl = `https://www.twitch.tv/${channel}`;
    const start = Date.now();
    window.location.href = appUrl;
    setTimeout(() => {
        if (Date.now() - start < 1500) window.open(browserUrl, '_blank');
    }, 800);
}


function _attachDotHandlers(matchRow, matchId) {
    // Les dots sont purement visuels (gagnant de chaque game)
    // Les stats des games terminées ne sont pas disponibles via l'API publique
}

function renderMatchRow(m) {
    const [a, b] = m.teams?.length >= 2 ? m.teams : [m.teams?.[0] || {}, {}];
    const isLive = m.state === 'inProgress';
    const isDone = m.state === 'completed';
    const bo = m.strategy?.count ? `BO${m.strategy.count}` : '';
    const leagueLabel = (m.league?.slug || '').toUpperCase();
    const stageParts = [leagueLabel, m.blockName, bo, isDone ? _matchFormatPast(m.startTime) : ''].filter(Boolean).map(_matchEsc).join(' · ');

    let badge;
    if (isLive) {
        badge = `<span class="jvcm-match-badge-live"><span class="jvcm-match-dot"></span>LIVE</span>`;
    } else if (isDone) {
        badge = `<span class="jvcm-match-badge-done">Terminé</span>`;
    } else {
        badge = `<span class="jvcm-match-badge-upcoming">${_matchEsc(_matchFormatStart(m.startTime))}</span>`;
    }

    const winA = a.outcome === 'win', winB = b.outcome === 'win';
    const clA  = isDone ? (winA ? 'winner' : winB ? 'loser' : '') : '';
    const clB  = isDone ? (winB ? 'winner' : winA ? 'loser' : '') : '';
    const middle = (isLive || isDone)
        ? `<span class="jvcm-match-score">${a.wins ?? 0} – ${b.wins ?? 0}</span>`
        : `<span class="jvcm-match-vs">vs</span>`;

    // Streams
    const streams = getStreamsForMatch(m);
    let streamsHtml = '';
    if (streams.length === 1) {
        streamsHtml = `<div class="jvcm-match-streams"><button class="jvcm-stream-btn" data-channel="${_matchEsc(streams[0])}">▶ ${_matchEsc(streams[0])}</button></div>`;
    } else if (streams.length > 1) {
        const opts = streams.map(s => `<option value="${_matchEsc(s)}">${_matchEsc(s)}</option>`).join('');
        streamsHtml = `<div class="jvcm-match-streams"><select class="jvcm-stream-select" data-stream-select><option value="">▶ Regarder sur Twitch</option>${opts}</select></div>`;
    }

    // Dots games (LIVE + terminés)
    let gamesHtml = '';
    if ((isLive || isDone) && m.games?.length) {
        let blueWins = 0, redWins = 0;
        const dots = m.games.map(g => {
            const st        = (g.state || '').toLowerCase();
            const completed = st === 'completed' || st === 'finished';
            const live      = st === 'inprogress' || st === 'in_progress';
            let dotClass = 'jvcm-game-dot';
            if (live) dotClass += ' live';
            if (completed) {
                dotClass += ' clickable';
                if (g.teams?.[0]?.result?.gameWins > blueWins)     { dotClass += ' win-blue'; blueWins = g.teams[0].result.gameWins; }
                else if (g.teams?.[1]?.result?.gameWins > redWins) { dotClass += ' win-red';  redWins  = g.teams[1].result.gameWins; }
            }
            const label = live ? '▶' : `G${g.number}`;
            return `<span class="${dotClass}" data-game-num="${g.number}" title="Game ${g.number}">${label}</span>`;
        }).join('');
        gamesHtml = `<div class="jvcm-match-games">${dots}</div>`;
    }

    // Lien lolesports
    const leagueSlug = m.league?.slug || '';
    const lolLink = leagueSlug
        ? `<a href="https://lolesports.com/schedule?leagues=${_matchEsc(leagueSlug)}" target="_blank" style="font-size:0.62rem;color:var(--jvc-text-muted);text-decoration:none;margin-left:auto;flex-shrink:0;padding:0.1rem 0.3rem;border:1px solid var(--jvc-border);border-radius:3px;">lolesports ↗</a>`
        : '';

    return `
        <div class="jvcm-match-row" data-state="${m.state}" data-match-id="${_matchEsc(m.matchId)}">
            <div class="jvcm-match-row-head">
                ${badge}
                <span class="jvcm-match-stage">${stageParts}</span>
                ${lolLink}
            </div>
            <div class="jvcm-match-row-body">
                <span class="jvcm-match-team ${clA}">${_matchEsc(a.code || a.name || 'TBD')}</span>
                ${middle}
                <span class="jvcm-match-team ${clB}">${_matchEsc(b.code || b.name || 'TBD')}</span>
            </div>
            ${gamesHtml}
            ${streamsHtml}
        </div>
    `;
}

// HTML partagé de la modale matchs — identique partout
function _matchesModalHTML() {
    return `
        <div id="jvcm-matches-overlay" class="hidden">
            <div id="jvcm-matches-panel">
                <div id="jvcm-matches-handle"></div>
                <div id="jvcm-matches-header">
                    <span id="jvcm-matches-title">🏆 Matchs esport</span>
                    <button id="jvcm-matches-refresh" title="Actualiser">⟳</button>
                    <button id="jvcm-matches-close">×</button>
                </div>
                <div id="jvcm-leagues-wrap"></div>
                <div id="jvcm-time-range">
                    <div class="jvcm-range-group">
                        <div class="jvcm-range-label">Passé <span id="jvcm-past-val">1j</span></div>
                        <input type="range" class="jvcm-range-input" id="jvcm-past-range" min="0" max="2" value="1" step="1">
                    </div>
                    <div class="jvcm-range-group">
                        <div class="jvcm-range-label">Futur <span id="jvcm-future-val">7j</span></div>
                        <input type="range" class="jvcm-range-input" id="jvcm-future-range" min="0" max="7" value="7" step="1">
                    </div>
                </div>
                <div id="jvcm-matches-loading" class="hidden">
                    <div class="jvcm-spinner"></div>
                    <span>Chargement des matchs…</span>
                </div>
                <div id="jvcm-matches-empty" class="hidden">Aucun match trouvé pour les ligues sélectionnées.</div>
                <div id="jvcm-matches-list"></div>
            </div>
        </div>`;
}

// ─────────────────────────────────────────────────────────────
// MATCHES UI — logique partagée entre JVChat et standalone
// ─────────────────────────────────────────────────────────────
function setupMatchesUI() {
    const overlay     = document.getElementById('jvcm-matches-overlay');
    const list        = document.getElementById('jvcm-matches-list');
    const loading     = document.getElementById('jvcm-matches-loading');
    const empty       = document.getElementById('jvcm-matches-empty');
    const leaguesWrap = document.getElementById('jvcm-leagues-wrap');
    const refreshBtn  = document.getElementById('jvcm-matches-refresh');

    loading.style.display = 'none';
    empty.style.display   = 'none';
    list.style.display    = 'block';

    const showLoading = () => { loading.style.display='flex'; empty.style.display='none'; list.style.display='none'; };
    const showEmpty   = (msg) => { loading.style.display='none'; empty.style.display='block'; list.style.display='none'; if (msg) empty.textContent = msg; };
    const showList    = () => { loading.style.display='none'; empty.style.display='none'; list.style.display='block'; };

    let selected = getSelectedLeagues();

    const renderLeagueBtns = () => {
        leaguesWrap.innerHTML = '';
        const groups = [...new Set(ALL_LEAGUES.map(l => l.group))];
        for (const group of groups) {
            const label = document.createElement('span');
            label.style.cssText = 'font-size:0.58rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--jvc-text-muted);width:100%;margin-top:0.3rem;font-family:Inter,system-ui,sans-serif;';
            label.textContent = group;
            leaguesWrap.appendChild(label);
            for (const league of ALL_LEAGUES.filter(l => l.group === group)) {
                const btn = document.createElement('button');
                btn.className = 'jvcm-league-btn' + (selected.includes(league.slug) ? ' active' : '');
                btn.textContent = league.label;
                btn.addEventListener('click', () => {
                    if (selected.includes(league.slug)) {
                        if (selected.length === 1) return;
                        selected = selected.filter(s => s !== league.slug);
                    } else {
                        selected = [...selected, league.slug];
                    }
                    setSelectedLeagues(selected);
                    renderLeagueBtns();
                    loadMatches();
                });
                leaguesWrap.appendChild(btn);
            }
        }
    };

    let _lastSelectedGame = {};
    let _pastDays   = 1;
    let _futureDays = 7;

    // Sliders fenêtre temporelle
    const pastRange   = document.getElementById('jvcm-past-range');
    const futureRange = document.getElementById('jvcm-future-range');
    const pastVal     = document.getElementById('jvcm-past-val');
    const futureVal   = document.getElementById('jvcm-future-val');

    const onRangeChange = () => {
        _pastDays   = parseInt(pastRange?.value   || '1');
        _futureDays = parseInt(futureRange?.value || '7');
        if (pastVal)   pastVal.textContent   = _pastDays   === 0 ? 'auj.' : `${_pastDays}j`;
        if (futureVal) futureVal.textContent = _futureDays === 0 ? 'auj.' : `${_futureDays}j`;
        // Invalider le cache pour forcer un rechargement avec les nouveaux paramètres
        const cacheKey = `jvcm:matches:${[...selected].sort().join(',')}_p${_pastDays}_f${_futureDays}`;
        try { localStorage.removeItem(cacheKey); } catch {}
        loadMatches();
    };

    let rangeTimer = null;
    const debouncedChange = () => { clearTimeout(rangeTimer); rangeTimer = setTimeout(onRangeChange, 400); };
    pastRange?.addEventListener('input', () => {
        _pastDays = parseInt(pastRange.value, 10) || 1;
        if (pastVal) pastVal.textContent = _pastDays === 0 ? 'auj.' : `${_pastDays}j`;
        debouncedChange();
    });
    futureRange?.addEventListener('input', () => {
        _futureDays = parseInt(futureRange.value, 10) || 7;
        if (futureVal) futureVal.textContent = _futureDays === 0 ? 'auj.' : `${_futureDays}j`;
        debouncedChange();
    }); // matchId → gameNum mémorisé

    const _renderSection = (label, matchesArr) => {
        if (!matchesArr.length) return '';
        const header = `<div style="padding:0.3rem 1rem;font-size:0.58rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--jvc-text-muted);background:var(--jvc-surface-2);border-bottom:1px solid var(--jvc-divider);">${label}</div>`;
        const html = matchesArr.map(m => {
            const div = document.createElement('div');
            div.innerHTML = renderMatchRow(m);
            return div.firstElementChild?.outerHTML || '';
        }).join('');
        return header + html;
    };

    const loadMatches = async () => {
        list.innerHTML = '';
        showLoading();
        refreshBtn.classList.add('spinning');
        try {
            const matches = await fetchMatches(selected, _pastDays, _futureDays);
            const now     = Date.now();
            const agoMs   = now - _pastDays   * 24 * 3600_000;
            const futMs   = now + _futureDays * 24 * 3600_000;
            const today   = new Date(); today.setHours(0,0,0,0);
            const tomorrow = new Date(today.getTime() + 86_400_000);
            const nextWeek = new Date(today.getTime() + 7 * 86_400_000);

            const live      = matches.filter(m => m.state === 'inProgress');
            const todayUp   = matches.filter(m => m.state === 'unstarted' && new Date(m.startTime) >= today    && new Date(m.startTime) < tomorrow);
            const weekUp    = matches.filter(m => m.state === 'unstarted' && new Date(m.startTime) >= tomorrow && new Date(m.startTime) < nextWeek);
            const completed = matches.filter(m => m.state === 'completed'  && new Date(m.startTime).getTime() >= agoMs).slice(0, 20);

            // Mettre à jour le badge 🏆 dans le header JVChat
            const matchesBtn = document.getElementById('jvcm-matches-btn');
            if (matchesBtn) {
                const existing = matchesBtn.querySelector('.jvcm-live-badge');
                if (live.length > 0) {
                    if (!existing) {
                        const badge = document.createElement('span');
                        badge.className = 'jvcm-live-badge';
                        badge.textContent = live.length;
                        matchesBtn.appendChild(badge);
                    } else {
                        existing.textContent = live.length;
                    }
                } else if (existing) {
                    existing.remove();
                }
            }

            refreshBtn.classList.remove('spinning');

            const total = live.length + todayUp.length + weekUp.length + completed.length;
            if (!total) { showEmpty('Aucun match trouvé pour les ligues sélectionnées.'); return; }

            // Injecter par sections
            let html = '';
            if (live.length)      html += _renderSection('🔴 En direct',       live);
            if (todayUp.length)   html += _renderSection("📅 Aujourd'hui",      todayUp);
            if (weekUp.length)    html += _renderSection('📆 Cette semaine',    weekUp);
            if (completed.length) html += _renderSection('✅ Récemment terminés', completed);
            list.innerHTML = html;

            // Brancher streams
            for (const btn of list.querySelectorAll('.jvcm-stream-btn[data-channel]')) {
                btn.addEventListener('click', () => openTwitch(btn.dataset.channel));
            }
            for (const sel of list.querySelectorAll('.jvcm-stream-select[data-stream-select]')) {
                sel.addEventListener('change', () => { if (sel.value) { openTwitch(sel.value); sel.value = ''; } });
            }
            showList();


            // Dots async pour les matchs terminés sans games dans le payload
            for (const row of list.querySelectorAll('.jvcm-match-row[data-state="completed"]')) {
                const matchId = row.dataset.matchId;
                if (!matchId) continue;
                // Si les dots sont déjà présents (payload avait m.games), skip
                if (row.querySelector('.jvcm-match-games')) continue;

                // Charger les games via getEventDetails
                gmFetch(`${LOLESPORTS_API}/getEventDetails?hl=fr-FR&id=${matchId}`, { 'x-api-key': LOLESPORTS_KEY })
                    .then(details => {
                        const games = details?.data?.event?.match?.games || [];
                        if (!games.length) return;
                        const teams = details?.data?.event?.match?.teams || [];
                        const t1Wins = teams[0]?.result?.gameWins ?? 0;
                        const t2Wins = teams[1]?.result?.gameWins ?? 0;

                        let blueW = 0, redW = 0;
                        const dots = games.map(g => {
                            const st = (g.state || '').toLowerCase();
                            const completed = st === 'completed' || st === 'finished';
                            let cls = 'jvcm-game-dot' + (completed ? ' clickable' : '');
                            if (completed) {
                                // Déduire le gagnant game par game depuis les wins cumulés
                                const newBlue = teams[0]?.result?.gameWins ?? 0;
                                const newRed  = teams[1]?.result?.gameWins ?? 0;
                                if (g.number <= t1Wins)      cls += ' win-blue';
                                else if (g.number <= t1Wins + t2Wins) cls += ' win-red';
                            }
                            return `<span class="${cls}" data-game-num="${g.number}" title="Game ${g.number}">G${g.number}</span>`;
                        }).join('');

                        const gamesDiv = document.createElement('div');
                        gamesDiv.className = 'jvcm-match-games';
                        gamesDiv.innerHTML = dots;

                        // Insérer avant le bloc streams
                        const streamsEl = row.querySelector('.jvcm-match-streams');
                        if (streamsEl) row.insertBefore(gamesDiv, streamsEl);
                        else row.appendChild(gamesDiv);

                        // Brancher les dots
                        _attachDotHandlers(row, matchId);
                    }).catch(() => {});
            }



            // Refresh auto toutes les 30s si des matchs LIVE
        } catch (e) {
            refreshBtn.classList.remove('spinning');
            showEmpty('Erreur de chargement. Réessayez.');
            err('fetchMatches:', e);
        }
    };

    const open  = () => {
        overlay.style.display = 'flex';
        overlay.classList.remove('hidden');
        renderLeagueBtns();
        loadMatches();
    };
    const close = () => {
        overlay.style.display = 'none';
        overlay.classList.add('hidden');

    };

    document.getElementById('jvcm-matches-close').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });

    refreshBtn.addEventListener('click', () => {
        if (refreshBtn.classList.contains('spinning')) return;
        const cacheKey = `jvcm:matches:${[...selected].sort().join(',')}_p${_pastDays}_f${_futureDays}`;
        try { localStorage.removeItem(cacheKey); } catch {}
        loadMatches();
    });

    // Swipe bas pour fermer
    const handle = document.getElementById('jvcm-matches-handle');
    if (handle) {
        let startY = 0;
        handle.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; }, { passive: true });
        handle.addEventListener('touchend',   (e) => { if (e.changedTouches[0].clientY - startY > 60) close(); }, { passive: true });
    }

    return { open, close };
}
// ─────────────────────────────────────────────────────────────
class JVChatMobile {
    constructor() {
        this.messages        = [];
        this.lastPage        = 1;
        this.payload         = null;
        this.connectedUser   = null;
        this.autoScroll      = true;
        this.unreadOnScroll  = 0;
        this.unreadTotal     = 0;
        this.refreshRate     = 6000;
        this.isTabActive     = true;
        this._cycle          = 0;
        this._retryDelay     = 6000;
        this._baseTitle      = '';
        this._newSepInserted = false;
        this._relTimerID     = null;
        this.settings        = new Settings();
        // Confirmation suppression
        this._pendingDelete  = null;
    }

    async start() {
        addStyle();
        const info = this._parseURL(location.href);
        this.viewId = info.viewId; this.forumId = info.forumId;
        this.topicId = info.topicId; this.topicTitle = info.title;
        this.api = new JVCAPI(this.viewId, this.forumId, this.topicId, this.topicTitle);

        this._buildUI();
        await this._loadInitial();

        let lastRefresh = performance.now();
        this._resetPollTimer = () => { lastRefresh = 0; }; // remet le timer à 0 pour forcer un poll immédiat
        setInterval(() => {
            if (performance.now() - lastRefresh >= this.refreshRate) {
                lastRefresh = performance.now();
                this._poll();
            }
        }, 50);

        this._relTimerID = setInterval(() => this._updateRelativeTimes(), 60_000);

        document.addEventListener('visibilitychange', () => {
            this.isTabActive = document.visibilityState === 'visible';
            if (this.isTabActive) {
                this.unreadTotal = 0;
                updateFavicon(0);
                if (this._baseTitle) document.title = this._baseTitle;
                this.autoScroll = true;
                this.unreadOnScroll = 0;
                this._newSepInserted = false;
                    this._scrollToBottom();
            }
        });

        if (window.visualViewport) {
            window.visualViewport.addEventListener('resize', () => this._onViewportResize());
        }
    }

    _parseURL(url) {
        const m = url.match(/^https:\/\/www\.jeuxvideo\.com\/(?:recherche\/)?forums\/(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(.*?)\.htm/);
        if (!m) throw new Error('URL invalide');
        return { viewId: +m[1], forumId: +m[2], topicId: +m[3], title: m[8] };
    }

    _buildUI() {
        for (const el of document.body.children) el.style.display = 'none';
        document.documentElement.style.overflow = 'hidden';
        document.body.style.cssText = 'margin:0;padding:0;overflow:hidden;background:var(--jvc-bg,#18181b)';

        document.body.insertAdjacentHTML('beforeend', `
            <div id="jvcm-app">
                <header id="jvcm-header">
                    <button id="jvcm-home-btn" title="Retour à la liste des topics">⌂</button>
                    <span id="jvcm-topic-title" title="Fermer JVChat">Chargement…</span>
                    <div id="jvcm-meta">
                        <span class="jvcm-chip jvcm-chip--live" id="jvcm-connected">–</span>
                    </div>
                    <button id="jvcm-matches-btn" title="Matchs esport">🏆</button>
                    <button id="jvcm-settings-btn" title="Paramètres">⚙</button>
                </header>

                <div id="jvcm-loading">
                    <div class="jvcm-spinner"></div>
                    <span class="jvcm-loading-label">Chargement des messages…</span>
                </div>

                <div id="jvcm-messages"></div>


                <div id="jvcm-input-wrap">
                    <div id="jvcm-input-bar">
                        <textarea id="jvcm-textarea" rows="1" placeholder="Votre message…"></textarea>
                        <button id="jvcm-clear-btn" aria-label="Vider le message" title="Vider">×</button>
                        <button id="jvcm-send-btn" aria-label="Envoyer">
                            <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
                        </button>
                    </div>
                </div>
            </div>

            <!-- Modale matchs esport -->
            ${_matchesModalHTML()}

            <div id="jvcm-settings">
                <div id="jvcm-settings-header">
                    ⚙ Paramètres
                    <button id="jvcm-settings-close">×</button>
                </div>
                <div id="jvcm-settings-body"></div>
            </div>

            <!-- Modal générique (report + confirm delete) -->
            <div id="jvcm-modal-overlay" class="jvcm-modal-overlay hidden">
                <div class="jvcm-modal" id="jvcm-modal">
                    <div class="jvcm-modal-header">
                        <span id="jvcm-modal-title"></span>
                        <button class="jvcm-modal-close" id="jvcm-modal-close">×</button>
                    </div>
                    <div class="jvcm-modal-body" id="jvcm-modal-body"></div>
                    <div class="jvcm-modal-footer">
                        <button class="jvcm-modal-cancel" id="jvcm-modal-cancel">Annuler</button>
                        <button class="jvcm-modal-confirm" id="jvcm-modal-confirm">Confirmer</button>
                    </div>
                </div>
            </div>
        `);

        this.$messages  = document.getElementById('jvcm-messages');
        this.$textarea  = document.getElementById('jvcm-textarea');
        this.$sendBtn   = document.getElementById('jvcm-send-btn');
        this.$loading   = document.getElementById('jvcm-loading');


        // Bouton home → retour liste des topics
        const forumLink = document.querySelector('.spreadContainer nav.breadcrumb > a[href^="/forums/0-"]')
                       || document.querySelector('nav.breadcrumb a[href*="/forums/0-"]')
                       || document.querySelector('a[href*="/forums/0-19163"]')
                       || document.querySelector('a[href*="/forums/0-42"]');
        const forumUrl  = forumLink?.href || null;
        const homeBtn   = document.getElementById('jvcm-home-btn');
        if (forumUrl) {
            homeBtn.addEventListener('click', () => { window.location.href = forumUrl; });
        } else {
            homeBtn.style.display = 'none';
        }

        // Fermer JVChat
        document.getElementById('jvcm-topic-title').addEventListener('click', () => {
            clearInterval(this._relTimerID);
            document.getElementById('jvcm-app')?.remove();
            document.getElementById('jvcm-settings')?.remove();
            document.getElementById('jvcm-modal-overlay')?.remove();
            document.documentElement.style.overflow = '';
            document.body.style.cssText = '';
            for (const el of document.body.children) el.style.display = '';
            location.reload();
        });

        // Fermer actions si clic ailleurs
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.jvcm-message')) {
                for (const m of document.querySelectorAll('.jvcm-message.is-active')) m.classList.remove('is-active');
            }
        });

        // Textarea + compteur de caractères
        const clearBtn = document.getElementById('jvcm-clear-btn');
        this.$clearBtn = clearBtn;
        clearBtn.addEventListener('click', () => {
            this.$textarea.value = '';
            this.$textarea.style.height = '';
            clearBtn.classList.remove('visible');
            this.$textarea.focus();
        });

        this.$textarea.addEventListener('input', () => {
            this.$textarea.style.height = 'auto';
            this.$textarea.style.height = Math.min(this.$textarea.scrollHeight, 120) + 'px';
            clearBtn.classList.toggle('visible', this.$textarea.value.length > 0);
        });
        this.$sendBtn.addEventListener('click', () => this._send());
        this.$textarea.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && !e.shiftKey && this.settings.get('send_on_enter')) {
                e.preventDefault(); this._send();
            }
        });

        // Scroll
        this.$messages.addEventListener('scroll', () => {
            const atBottom = this.$messages.scrollHeight - this.$messages.scrollTop <= this.$messages.clientHeight + 2;
            this.autoScroll = atBottom;
            if (atBottom) {
                this.unreadOnScroll = 0;
                    // Retirer le séparateur "nouveaux messages" quand on arrive en bas
                document.getElementById('jvcm-new-sep')?.remove();
                this._newSepInserted = false;
            }
        }, { passive: true });

        // Settings panel + swipe bas pour fermer
        this._buildSettings();
        document.getElementById('jvcm-settings-btn').addEventListener('click', () => {
            document.getElementById('jvcm-settings').classList.add('open');
        });
        document.getElementById('jvcm-settings-close').addEventListener('click', () => {
            document.getElementById('jvcm-settings').classList.remove('open');
        });
        this._setupSettingsSwipe();

        // Modale matchs
        this._setupMatchesModal();

        // Modal générique
        this._setupModal();
    }

    _setupMatchesModal() {
        const { open } = setupMatchesUI();
        document.getElementById('jvcm-matches-btn').addEventListener('click', open);
    }

    _setupSettingsSwipe() {
        const panel = document.getElementById('jvcm-settings');
        const header = document.getElementById('jvcm-settings-header');
        let startY = 0, dragging = false;

        header.addEventListener('touchstart', (e) => {
            startY = e.touches[0].clientY;
            dragging = true;
            panel.style.transition = 'none';
        }, { passive: true });

        header.addEventListener('touchmove', (e) => {
            if (!dragging) return;
            const dy = e.touches[0].clientY - startY;
            if (dy > 0) panel.style.transform = `translateY(${dy}px)`;
        }, { passive: true });

        header.addEventListener('touchend', (e) => {
            if (!dragging) return;
            dragging = false;
            panel.style.transition = '';
            const dy = e.changedTouches[0].clientY - startY;
            if (dy > 80) {
                panel.classList.remove('open');
                panel.style.transform = '';
            } else {
                panel.style.transform = '';
            }
        });
    }

    _setupModal() {
        const overlay = document.getElementById('jvcm-modal-overlay');
        const close = () => {
            overlay.classList.add('hidden');
            this._pendingDelete = null;
            document.getElementById('jvcm-modal-confirm').onclick = null;
        };
        document.getElementById('jvcm-modal-close').addEventListener('click', close);
        document.getElementById('jvcm-modal-cancel').addEventListener('click', close);
        overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    }

    _showModal({ title, body, confirmLabel, confirmClass = '', onConfirm }) {
        document.getElementById('jvcm-modal-title').textContent = title;
        document.getElementById('jvcm-modal-body').innerHTML = body;
        const btn = document.getElementById('jvcm-modal-confirm');
        btn.textContent = confirmLabel;
        btn.className = 'jvcm-modal-confirm ' + confirmClass;
        btn.onclick = () => {
            document.getElementById('jvcm-modal-overlay').classList.add('hidden');
            onConfirm();
        };
        document.getElementById('jvcm-modal-overlay').classList.remove('hidden');
    }

    _buildSettings() {
        const body = document.getElementById('jvcm-settings-body');
        for (const def of this.settings.defs) {
            const id = `jvcm-s-${def.key}`;
            const checked = this.settings.get(def.key) ? 'checked' : '';
            body.insertAdjacentHTML('beforeend', `
                <div class="jvcm-setting-row">
                    <div>
                        <div class="jvcm-setting-label">${def.label}</div>
                        ${def.desc ? `<div class="jvcm-setting-desc">${def.desc}</div>` : ''}
                    </div>
                    <label class="jvcm-toggle">
                        <input type="checkbox" id="${id}" ${checked}>
                        <span class="jvcm-toggle-track"></span>
                    </label>
                </div>
            `);
            document.getElementById(id).addEventListener('change', async (e) => {
                const val = e.target.checked;
                if (def.key === 'notifications' && val) {
                    const ok = await requestNotifPermission();
                    if (!ok) { e.target.checked = false; this._toast('Permission refusée', 'error'); return; }
                }
                this.settings.set(def.key, val);
            });
        }
    }

    _onViewportResize() {
        const app = document.getElementById('jvcm-app');
        if (!app) return;
        app.style.height = window.visualViewport.height + 'px';
        if (this.autoScroll) this._scrollToBottom();
    }

    // ── Chargement initial ──
    async _loadInitial() {
        this.$loading.classList.remove('hidden');
        try {
            const currentPage = parsePage(document);
            this.lastPage = currentPage.lastPage;

            const lastDoc = await this.api.getPageDocument(this.lastPage);
            const lastParsed = parsePage(lastDoc);

            const loadPages = async (finalPage, finalDoc, finalParsed) => {
                this.payload = getPayload(finalDoc);
                this.api.payload = this.payload;
                this.connectedUser = this._getConnectedUser(finalDoc);
                this._updateHeader(finalParsed);
                if (finalPage >= 2) {
                    const prevDoc = await this.api.getPageDocument(finalPage - 1);
                    for (const m of parsePage(prevDoc).messages) this._addMessage(m, false);
                }
                for (const m of finalParsed.messages) this._addMessage(m, false);
            };

            if (lastParsed.lastPage > this.lastPage) {
                this.lastPage = lastParsed.lastPage;
                const realDoc = await this.api.getPageDocument(this.lastPage);
                await loadPages(this.lastPage, realDoc, parsePage(realDoc));
            } else {
                await loadPages(this.lastPage, lastDoc, lastParsed);
            }

            this._recheckMentions();
            this._baseTitle = document.title;
            this.$loading.classList.add('hidden');
            this._scrollToBottom(true);
        } catch (e) {
            err('loadInitial:', e);
            this.$loading.classList.add('hidden');
            this._toast('Erreur de chargement — nouvelle tentative dans 10s', 'error');
            setTimeout(() => this._loadInitial(), 10000);
        }
    }

    // ── Polling ──
    async _poll() {
        const c = (this._cycle = (this._cycle || 0) + 1);
        try {
            const doc = await this.api.getPageDocument(this.lastPage);
            const page = parsePage(doc);
            this.payload = getPayload(doc);
            this.api.payload = this.payload;
            this.lastPage = page.lastPage;
            this.connectedUser = this._getConnectedUser(doc);
            this._updateHeader(page);

            for (const m of page.messages) {
                if (!this.messages.find(x => x.id === m.id)) this._addMessage(m, true);
            }

            if (page.messages.length >= 20) {
                try {
                    const nextDoc = await this.api.getPageDocument(this.lastPage + 1);
                    const next = parsePage(nextDoc);
                    if (next.lastPage > this.lastPage) {
                        this.lastPage = next.lastPage;
                        for (const m of next.messages) {
                            if (!this.messages.find(x => x.id === m.id)) this._addMessage(m, true);
                        }
                    }
                } catch {}
            }

            const recent = this.messages.filter(m => m.page >= this.lastPage - 1).slice(-20);
            for (const m of recent) {
                if (m.isDeleted) continue;
                if (!page.messages.find(x => x.id === m.id)) {
                    const raw = await this.api.getMessage(m.id);
                    if (!raw) { m.element?.classList.add('is-deleted'); m.isDeleted = true; }
                }
            }

            if (this.autoScroll) this._scrollToBottom();
            if (this._retryDelay > 6000) { this._retryDelay = 6000; this.refreshRate = 6000; }
        } catch (e) {
            err(`Cycle #${c}:`, e);
            this._retryDelay = Math.min(this._retryDelay * 2, 48000);
            this.refreshRate = this._retryDelay;
            this._toast(`Reconnexion dans ${this._retryDelay / 1000}s…`, 'info');
            setTimeout(() => { this.refreshRate = 6000; }, this._retryDelay + 500);
        }
    }

    _updateHeader(page) {
        const t = document.getElementById('jvcm-topic-title');
        const c = document.getElementById('jvcm-connected');
        if (t) t.textContent = page.title || '';
        if (c) {
            const n = this.payload?.forumInfo?.header?.btnVal ?? page.connectedCount;
            c.textContent = `${n} connecté${n !== 1 ? 's' : ''}`;
        }
    }

    _getConnectedUser(doc) {
        if (!doc.querySelector('.headerAccount__pm')) return null;
        const username = doc.querySelector('.headerAccount__pseudo')?.textContent.trim();
        const avatarUrl = doc.querySelector('.headerAccount__avatar')?.style.backgroundImage
            .slice(5, -2).replace('/avatar-md/', '/avatar/');
        return { username, avatarUrl };
    }

    // ── Ajout d'un message ──
    _addMessage(message, isNew) {
        if (this.messages.find(m => m.id === message.id)) return;

        // Séparateur "nouveaux messages"
        if (isNew && !this._newSepInserted && !this.autoScroll) {
            this._newSepInserted = true;
            this._pendingNewCount = 1;
            const sep = document.createElement('button');
            sep.className = 'jvcm-new-sep'; sep.id = 'jvcm-new-sep';
            sep.textContent = '↓ Nouveaux messages';
            sep.addEventListener('click', () => {
                this._scrollToBottom();
                this.autoScroll = true;
                sep.remove();
                this._newSepInserted = false;
            });
            // Insérer entre $messages et la zone de saisie, pas dans le scroll
            const inputWrap = document.getElementById('jvcm-input-wrap');
            if (inputWrap) inputWrap.insertAdjacentElement('beforebegin', sep);
            else this.$messages.appendChild(sep);
        } else if (isNew && this._newSepInserted) {
            this._pendingNewCount = (this._pendingNewCount || 1) + 1;
            const sep = document.getElementById('jvcm-new-sep');
            if (sep) sep.textContent = `${this._pendingNewCount} nouveaux messages`;
        }

        if (isNew && !this.autoScroll) { this.unreadOnScroll++; this._updateScrollBtn(); }

        if (isNew && !this.isTabActive) {
            this.unreadTotal++;
            updateFavicon(this.unreadTotal);
            document.title = `(${this.unreadTotal}) ${this._baseTitle || document.title.replace(/^\(\d+\)\s*/, '')}`;
            if (this.settings.get('notifications')) {
                sendNotif(`${message.username} — JVChat`, message.content.replace(/<[^>]+>/g, '').slice(0, 80));
            }
        }

        const prev = this.messages[this.messages.length - 1];
        const grouped = prev && prev.username === message.username;
        const hue = pseudoHue(message.username);
        const ts = parseJVCDate(message.creationDate);
        const relTxt = ts ? relativeTime(ts) : message.creationDate.slice(-8);
        const absTxt = message.creationDate.slice(-8);
        const isOwn = this.connectedUser && this.connectedUser.username === message.username;

        // Avatar : afficher l'URL originale immédiatement, remplacer par le blob caché en arrière-plan
        const avatarSrc = _avatarCache.get(message.avatarUrl) || message.avatarUrl;

        const el = document.createElement('article');
        el.className = 'jvcm-message' + (grouped ? ' is-grouped' : '');
        el.dataset.id = message.id;
        if (ts) el.dataset.ts = ts;

        el.innerHTML = `
            <span class="jvcm-swipe-hint">💬</span>
            <img class="jvcm-avatar" src="${avatarSrc}" alt="" loading="lazy">
            <div class="jvcm-msg-body">
                <div class="jvcm-msg-header">
                    <span class="jvcm-username" style="--pseudo-h:${hue}">${message.username}</span>
                        ${message.userRank ? `<span class="jvcm-userrank">${message.userRank}</span>` : ''}
                    <span class="jvcm-date" title="${message.creationDate}">
                        <span class="jvcm-date-rel">${relTxt}</span>
                        <span class="jvcm-date-abs">${absTxt}</span>
                    </span>
                    <div class="jvcm-actions">
                        <div class="jvcm-actions-row">
                            <button class="jvcm-action-btn jvcm-btn-quote">💬 Citer</button>
                            <button class="jvcm-action-btn jvcm-btn-copy">🔗</button>
                        </div>
                        <div class="jvcm-actions-row">
                            ${isOwn ? `<button class="jvcm-action-btn jvcm-btn-edit">✏️ Éditer</button>` : ''}
                            ${isOwn ? `<button class="jvcm-action-btn danger jvcm-btn-delete">🗑️</button>` : ''}
                            ${!isOwn ? `<button class="jvcm-action-btn danger jvcm-btn-report">🚩</button>` : ''}
                        </div>
                    </div>
                </div>
                <div class="jvcm-content txt-msg text-enrichi-forum">${message.content}</div>
            </div>
        `;

        // Toggle date abs/rel
        el.querySelector('.jvcm-date')?.addEventListener('click', (e) => {
            e.stopPropagation();
            el.querySelector('.jvcm-date').classList.toggle('show-abs');
        });

        // Fix JvCare
        for (const jvc of el.querySelectorAll('.JvCare')) {
            const a = document.createElement('a');
            a.href = jvCake(jvc.getAttribute('class')); a.target = '_blank';
            a.innerHTML = jvc.innerHTML; jvc.replaceWith(a);
        }

        // Fix img-shack
        for (const img of el.querySelectorAll('.img-shack')) {
            const parent = img.parentElement;
            const a = document.createElement('a');
            a.href = jvCake(parent.getAttribute('class')); a.target = '_blank';
            a.appendChild(img.cloneNode(true)); parent.replaceWith(a);
        }

        // Citations imbriquées
        for (const bq of el.querySelectorAll('.text-enrichi-forum > blockquote > blockquote')) {
            const btn = document.createElement('button');
            btn.className = 'jvcm-collapse-btn'; btn.textContent = '⟲ Voir la citation';
            bq.prepend(btn);
            btn.addEventListener('click', (e) => {
                e.stopPropagation();
                btn.closest('.message__blockquote, blockquote').classList.toggle('is-open');
            });
        }

        // Scroll vers citation
        for (const bq of el.querySelectorAll('.jvcm-content > blockquote, .jvcm-content > .message__blockquote')) {
            bq.addEventListener('click', (e) => {
                if (e.target.closest('.jvcm-collapse-btn')) return;
                e.stopPropagation();
                this._scrollToQuoteOrigin(bq);
            });
        }

        this._checkMention(el, message);

        // Tap → toggle actions
        el.addEventListener('click', (e) => {
            if (e.target.closest('a, button, input, textarea, .jvcm-date, blockquote')) return;
            if (window.getSelection?.().toString().length > 0) return;
            const wasActive = el.classList.contains('is-active');
            for (const other of document.querySelectorAll('.jvcm-message.is-active')) other.classList.remove('is-active');
            if (!wasActive) el.classList.add('is-active');
        });

        attachLongPress(el, () => {
            el.classList.add('is-pressing');
            setTimeout(() => el.classList.remove('is-pressing'), 300);
            if (!el.classList.contains('is-active')) {
                for (const other of document.querySelectorAll('.jvcm-message.is-active')) other.classList.remove('is-active');
                el.classList.add('is-active');
            }
        });

        if (this.settings.get('swipe_to_quote')) this._attachSwipeQuote(el, message);

        // Bouton copier lien du message
        el.querySelector('.jvcm-btn-copy')?.addEventListener('click', (e) => {
            e.stopPropagation();
            el.classList.remove('is-active');
            const url = `https://www.jeuxvideo.com/forums/message/${message.id}`;
            const doCopy = () => {
                if (navigator.clipboard?.writeText) {
                    return navigator.clipboard.writeText(url);
                }
                // Fallback iOS Safari / anciens navigateurs
                const ta = document.createElement('textarea');
                ta.value = url;
                ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
                document.body.appendChild(ta);
                ta.focus(); ta.select();
                try { document.execCommand('copy'); } catch {}
                ta.remove();
                return Promise.resolve();
            };
            doCopy().then(() => this._toast('Lien copié', 'success')).catch(() => this._toast(url, 'info'));
        });

        // Bouton citer
        el.querySelector('.jvcm-btn-quote')?.addEventListener('click', (e) => {
            e.stopPropagation();
            this._quoteMessage(message);
            el.classList.remove('is-active');
        });

        // Bouton éditer
        el.querySelector('.jvcm-btn-edit')?.addEventListener('click', async (e) => {
            e.stopPropagation();
            const btn = el.querySelector('.jvcm-btn-edit');
            btn.disabled = true; btn.textContent = '…';
            try {
                await this._editMessage(message);
                el.classList.remove('is-active');
            } catch {
                btn.style.display = 'none';
                this._toast("Édition non disponible", 'error');
            }
        });

        // Bouton supprimer — confirmation avant suppression
        el.querySelector('.jvcm-btn-delete')?.addEventListener('click', (e) => {
            e.stopPropagation();
            el.classList.remove('is-active');
            this._showModal({
                title: '🗑️ Supprimer le message',
                body: `Supprimer définitivement ce message de <strong>${message.username}</strong> ? Cette action est irréversible.`,
                confirmLabel: 'Supprimer',
                confirmClass: 'danger',
                onConfirm: () => this._deleteMessage(message),
            });
        });

        // Bouton signaler
        el.querySelector('.jvcm-btn-report')?.addEventListener('click', (e) => {
            e.stopPropagation();
            el.classList.remove('is-active');
            this._showModal({
                title: '🚩 Faire un signalement',
                body: `Signaler le message de <strong>${message.username}</strong> ?<br><br>
                       Le formulaire JVC (avec captcha) s'ouvrira dans un nouvel onglet.`,
                confirmLabel: 'Signaler',
                confirmClass: '',
                onConfirm: () => {
                    window.open(`https://www.jeuxvideo.com/forums/message/${message.id}`, '_blank');
                    this._toast('Cliquez sur ⚠ dans le message pour signaler', 'info');
                },
            });
        });

        this.$messages.appendChild(el);
        message.element = el;
        this.messages.push(message);

        // Animation d'entrée pour les nouveaux messages
        if (isNew) {
            requestAnimationFrame(() => el.classList.add('is-new'));
        }

        // Forcer le chargement des images lazy et des backgrounds spoil JVC
        for (const img of el.querySelectorAll('img[loading="lazy"]')) {
            img.removeAttribute('loading'); // désactiver le lazy loading natif
        }
        for (const img of el.querySelectorAll('img[data-src], img[data-original], img[data-lazy]')) {
            img.src = img.dataset.src || img.dataset.original || img.dataset.lazy || img.src;
        }
        // Images spoil : data-src-background sur <span> → transformer en <img>
        for (const span of el.querySelectorAll('[data-src-background]')) {
            const src = span.dataset.srcBackground;
            if (!src) continue;
            const img = document.createElement('img');
            img.src = src;
            img.style.cssText = 'max-width:100%;display:block;border-radius:4px;';
            span.replaceWith(img);
        }

        // Mettre en cache l'avatar en arrière-plan et remplacer si l'URL change
        if (!_avatarCache.has(message.avatarUrl)) {
            cachedAvatar(message.avatarUrl).then(cached => {
                if (cached !== message.avatarUrl) {
                    const img = el.querySelector('.jvcm-avatar');
                    if (img) img.src = cached;
                }
            }).catch(() => {}); // CORS safe - pas de fetch
        }
    }

    _scrollToQuoteOrigin(bqNode) {
        const raw = bqNode.textContent.replace(/\s+/g, ' ').trim();
        const timeStr  = raw.match(/\d{2}:\d{2}:\d{2}/)?.[0] ?? null;
        const bodyMatch = raw.match(/\d{2}:\d{2}:\d{2}\s*:\s*(.+)/);
        const fragment  = bodyMatch ? bodyMatch[1].trim().slice(0, 60).toLowerCase() : null;

        if (!fragment) { this._toast('Impossible d\'identifier le message cité', 'info'); return; }

        let bestMatch = null;
        for (const m of this.messages) {
            if (!m.element) continue;
            const msgText = (m.element.querySelector('.jvcm-content')?.textContent || '')
                .replace(/\s+/g, ' ').trim().toLowerCase();
            if (msgText.includes(fragment)) {
                if (timeStr && m.creationDate && !m.creationDate.includes(timeStr)) continue;
                bestMatch = m.element; break;
            }
        }
        if (!bestMatch) {
            for (const m of this.messages) {
                if (!m.element) continue;
                const msgText = (m.element.querySelector('.jvcm-content')?.textContent || '')
                    .replace(/\s+/g, ' ').trim().toLowerCase();
                if (msgText.includes(fragment)) { bestMatch = m.element; break; }
            }
        }

        if (!bestMatch) { this._toast('Message original introuvable dans le chat chargé', 'info'); return; }

        this.autoScroll = false;

        const current = this.$messages.scrollTop;
        const cRect   = this.$messages.getBoundingClientRect();
        const tRect   = bestMatch.getBoundingClientRect();
        const center  = current + tRect.top - cRect.top
                      - (this.$messages.clientHeight / 2)
                      + (bestMatch.offsetHeight / 2);

        this.$messages.scrollTo({ top: Math.max(0, center), behavior: 'smooth' });
        bestMatch.classList.add('quote-flash');
        setTimeout(() => bestMatch.classList.remove('quote-flash'), 900);
    }

    _checkMention(el, message) {
        if (!this.connectedUser) return;
        const bq = el.querySelector('.jvcm-content > blockquote, .jvcm-content > .message__blockquote');
        if (bq && bq.textContent.toLowerCase().includes(this.connectedUser.username.toLowerCase())) {
            el.classList.add('mentions-me');
            if (navigator.vibrate && this.settings.get('vibration')) navigator.vibrate([80, 40, 80]);
        }
    }

    _recheckMentions() {
        for (const m of this.messages) {
            if (m.element && !m.element.classList.contains('mentions-me')) this._checkMention(m.element, m);
        }
    }

    _updateRelativeTimes() {
        for (const el of this.$messages.querySelectorAll('[data-ts]')) {
            const ts = parseInt(el.dataset.ts);
            if (!ts) continue;
            const rel = el.querySelector('.jvcm-date-rel');
            if (rel) rel.textContent = relativeTime(ts);
        }
    }

    _attachSwipeQuote(el, message) {
        let startX = 0, startY = 0, swiping = false;
        const THRESHOLD = 70;

        el.addEventListener('touchstart', (e) => {
            startX = e.touches[0].clientX; startY = e.touches[0].clientY; swiping = false;
        }, { passive: true });

        el.addEventListener('touchmove', (e) => {
            const dx = e.touches[0].clientX - startX;
            const dy = Math.abs(e.touches[0].clientY - startY);
            if (!swiping && Math.abs(dx) > 10 && dy < 20) swiping = true;
            if (!swiping || dx <= 0) return;
            el.style.transform = `translateX(${Math.min(dx, THRESHOLD * 1.4) * 0.45}px)`;
            el.classList.toggle('swipe-ready', dx >= THRESHOLD);
        }, { passive: true });

        const endSwipe = () => {
            if (swiping && el.classList.contains('swipe-ready')) {
                this._quoteMessage(message);
                this._toast('Citation ajoutée', 'info');
            }
            el.style.transform = ''; el.classList.remove('swipe-ready'); swiping = false;
        };
        el.addEventListener('touchend',    endSwipe, { passive: true });
        el.addEventListener('touchcancel', endSwipe, { passive: true });
    }

    // ── Envoi optimiste ──
    async _send() {
        const text = this.$textarea.value.trim();
        if (!text || !this.connectedUser) return;

        this.$textarea.value = '';
        this.$textarea.style.height = '';
        this.$clearBtn?.classList.remove('visible');
        this.$sendBtn.disabled = true;

        try {
            await this.api.postMessage(text);
            await this._poll();
            this._resetPollTimer?.();
        } catch (e) {
            // En cas d'erreur, remettre le texte dans le textarea
            this.$textarea.value = text;
            this.$textarea.dispatchEvent(new Event('input'));
            this._toast(e.message, 'error');
        } finally {
            this.$sendBtn.disabled = false;
        }
    }

    _quoteMessage(message) {
        const contentEl = message.element?.querySelector('.jvcm-content');
        if (!contentEl) return;
        const txt = reverseMessage(contentEl, true);
        const quote = `> Le ${message.creationDate} ${message.username} a écrit :\n${txt}\n\n`;
        const ta = this.$textarea;
        const before = ta.value.slice(0, ta.selectionStart ?? ta.value.length);
        const prefix = (before.length > 0 && !before.endsWith('\n\n')) ? '\n\n' : '';
        this._insertAtCursor(prefix + quote);
        this.$textarea.focus();
    }

    _insertAtCursor(text) {
        const ta = this.$textarea;
        const start = ta.selectionStart ?? ta.value.length;
        const end   = ta.selectionEnd   ?? ta.value.length;
        ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
        ta.selectionStart = ta.selectionEnd = start + text.length;
        ta.dispatchEvent(new Event('input', { bubbles: true }));
    }

    async _deleteMessage(message) {
        try {
            await this.api.deleteMessage(message.id);
            message.element?.classList.add('is-deleted');
            message.isDeleted = true;
        } catch (e) {
            this._toast(e.message, 'error');
        }
    }

    async _editMessage(message) {
        const { jvcode, formSession } = await this.api.getEditForm(message.id);
        const contentEl = message.element?.querySelector('.jvcm-content');
        if (!contentEl) return;
        const backup = contentEl.innerHTML;
        contentEl.innerHTML = `
            <textarea class="jvcm-edit-ta" style="width:100%;min-height:80px;background:var(--jvc-surface-2);color:var(--jvc-text);border:1px solid var(--jvc-border);border-radius:6px;padding:0.4rem 0.6rem;font-family:inherit;font-size:0.85rem;resize:vertical"></textarea>
            <div style="display:flex;gap:0.4rem;margin-top:0.4rem">
                <button class="jvcm-edit-cancel" style="flex:1;padding:0.35rem;background:var(--jvc-surface-3);border:1px solid var(--jvc-border);border-radius:5px;color:var(--jvc-text-dim);font-family:inherit;font-size:0.8rem;cursor:pointer">Annuler</button>
                <button class="jvcm-edit-submit" style="flex:1;padding:0.35rem;background:var(--jvc-orange);border:none;border-radius:5px;color:#fff;font-family:inherit;font-size:0.8rem;font-weight:600;cursor:pointer">Modifier</button>
            </div>
        `;
        const ta = contentEl.querySelector('.jvcm-edit-ta');
        ta.value = jvcode; ta.focus();
        contentEl.querySelector('.jvcm-edit-cancel').addEventListener('click', () => { contentEl.innerHTML = backup; });
        contentEl.querySelector('.jvcm-edit-submit').addEventListener('click', async () => {
            try {
                contentEl.innerHTML = await this.api.updateMessage(message.id, ta.value, formSession);
                this._toast('Message modifié', 'success');
            } catch (e) {
                this._toast(e.message, 'error');
                contentEl.innerHTML = backup;
            }
        });
    }

    _scrollToBottom(force = false) {
        if (!force) {
            requestAnimationFrame(() => {
                this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'smooth' });
            });
            return;
        }
        const imgs = [...this.$messages.querySelectorAll('img')].filter(i => !i.complete);
        if (imgs.length === 0) {
            requestAnimationFrame(() => this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'instant' }));
            return;
        }
        let settled = false;
        const doScroll = () => {
            if (settled) return; settled = true;
            this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'instant' });
        };
        let loaded = 0;
        for (const img of imgs) {
            const done = () => { if (++loaded >= imgs.length) doScroll(); };
            img.addEventListener('load', done, { once: true });
            img.addEventListener('error', done, { once: true });
        }
        setTimeout(doScroll, 1200);
    }



    _toast(msg, type = 'info') {
        const el = document.createElement('div');
        el.className = `jvcm-toast ${type}`;
        el.textContent = msg;
        document.body.appendChild(el);
        setTimeout(() => el.remove(), 4000);
    }
}

// ─────────────────────────────────────────────────────────────
// DÉTECTION FORUM LOL
// ─────────────────────────────────────────────────────────────
const LOL_KEYWORDS = /league\s*of\s*legends|\blol\b|lec|lck|lpl|lfl|esport/i;
const LOL_FORUM_IDS = new Set(['19163', '42']); // IDs des forums LoL sur JVC

function isLolContext() {
    // Priorité 1 : ID du forum dans l'URL
    const forumIdMatch = location.href.match(/\/forums\/(?:0-)?(\d+)-/);
    if (forumIdMatch && LOL_FORUM_IDS.has(forumIdMatch[1])) return true;
    // Priorité 2 : mots-clés dans le fil d'Ariane / titre / nom du forum
    const breadcrumb = document.querySelector('.spreadContainer nav.breadcrumb, .breadcrumb')?.textContent || '';
    const title      = document.title || '';
    const forumName  = document.querySelector('.spreadContainer nav.breadcrumb > a[href^="/forums/0-"]')?.textContent || '';
    return LOL_KEYWORDS.test(breadcrumb + title + forumName);
}

// ─────────────────────────────────────────────────────────────
// MODALE MATCHS STANDALONE (utilisable hors JVChat)
// ─────────────────────────────────────────────────────────────
function injectStandaloneMatchesModal() {
    if (document.getElementById('jvcm-matches-overlay')) return setupMatchesUI(); // déjà injecté, rebrancher

    // Injecter les variables CSS + styles de base si JVChat n'est pas actif
    if (!document.getElementById('jvcm-style')) {
        const style = document.createElement('style');
        style.id = 'jvcm-standalone-style';
        style.textContent = `
            @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
            :root {
                --jvc-bg:#18181b; --jvc-surface-1:#1f1f23; --jvc-surface-2:#27272a;
                --jvc-surface-3:#303035; --jvc-border:#2e2e33; --jvc-divider:#242428;
                --jvc-text:#e4e4e7; --jvc-text-dim:#a1a1aa; --jvc-text-muted:#71717a;
                --jvc-orange:#e84d1a; --jvc-green:#4ade80; --jvc-red:#f87171; --jvc-blue:#7fb4ff;
            }
            @keyframes jvcm-spin    { to { transform: rotate(360deg); } }
            @keyframes jvcm-breathe { 0%,100%{opacity:1} 50%{opacity:0.3} }
            .jvcm-spinner { width:28px;height:28px;border:3px solid var(--jvc-surface-3);border-top-color:var(--jvc-orange);border-radius:50%;animation:jvcm-spin 0.7s linear infinite; }
            /* Modale structure */
            #jvcm-matches-overlay { position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9001;display:flex;align-items:flex-end;justify-content:center; }
            #jvcm-matches-overlay.hidden { display:none; }
            #jvcm-matches-panel { background:var(--jvc-bg);border:1px solid var(--jvc-border);border-radius:12px 12px 0 0;width:100%;max-width:520px;max-height:85dvh;display:flex;flex-direction:column;overflow:hidden;font-family:Inter,system-ui,sans-serif; }
            #jvcm-matches-handle { width:36px;height:4px;background:var(--jvc-border);border-radius:2px;margin:10px auto 0;flex-shrink:0; }
            #jvcm-matches-header { display:flex;align-items:center;padding:0.75rem 1rem 0.5rem;gap:0.5rem;flex-shrink:0; }
            #jvcm-matches-title { font-size:0.9rem;font-weight:700;color:var(--jvc-text);flex:1; }
            #jvcm-matches-refresh { background:none;border:none;color:var(--jvc-text-muted);font-size:1rem;cursor:pointer;padding:0.2rem 0.4rem;border-radius:4px; }
            #jvcm-matches-close { background:none;border:none;color:var(--jvc-text-muted);font-size:1.3rem;cursor:pointer;line-height:1;padding:0.2rem; }
            #jvcm-leagues-wrap { padding:0 1rem 0.6rem;display:flex;flex-wrap:wrap;gap:0.35rem;flex-shrink:0;border-bottom:1px solid var(--jvc-border); }
            #jvcm-matches-loading { display:none;text-align:center;padding:2rem 1rem;color:var(--jvc-text-muted);font-size:0.82rem;flex-direction:column;align-items:center;gap:0.75rem; }
            #jvcm-matches-loading.hidden { display:none; }
            #jvcm-matches-empty { display:none;text-align:center;padding:2rem 1rem;color:var(--jvc-text-muted);font-size:0.82rem; }
            #jvcm-matches-list { flex:1;overflow-y:auto;padding:0.5rem 0; }
            /* Sliders */
            #jvcm-time-range { padding:0.5rem 1rem 0.4rem;border-bottom:1px solid var(--jvc-border);display:flex;gap:1rem;flex-shrink:0; }
            .jvcm-range-group { flex:1;display:flex;flex-direction:column;gap:0.25rem; }
            .jvcm-range-label { font-size:0.6rem;font-weight:600;color:var(--jvc-text-muted);text-transform:uppercase;letter-spacing:0.08em;display:flex;justify-content:space-between; }
            .jvcm-range-label span { color:var(--jvc-text);font-variant-numeric:tabular-nums; }
            .jvcm-range-input { -webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--jvc-surface-3);border-radius:2px;outline:none; }
            .jvcm-range-input::-webkit-slider-thumb { -webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--jvc-orange);cursor:pointer; }
            #jvcm-matches-refresh.spinning { animation:jvcm-spin 0.7s linear infinite;color:var(--jvc-orange); }
            .jvcm-league-btn { padding:0.25rem 0.6rem;border-radius:999px;font-size:0.68rem;font-weight:600;font-family:Inter,system-ui,sans-serif;cursor:pointer;background:var(--jvc-surface-2);border:1px solid var(--jvc-border);color:var(--jvc-text-muted);transition:all 150ms ease; }
            .jvcm-league-btn.active { background:var(--jvc-orange);border-color:var(--jvc-orange);color:#fff; }
            .jvcm-match-row { padding:0.55rem 1rem;border-bottom:1px solid var(--jvc-divider);display:flex;flex-direction:column;gap:0.25rem;font-family:Inter,system-ui,sans-serif; }
            .jvcm-match-row:last-child { border-bottom:none; }
            .jvcm-match-row[data-state="inProgress"] { background:rgba(239,68,68,0.04);border-left:2px solid rgba(239,68,68,0.5); }
            .jvcm-match-row-head { display:flex;align-items:center;gap:0.4rem;font-size:0.58rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase; }
            .jvcm-match-badge-live { color:#ef4444;display:flex;align-items:center;gap:0.3rem; }
            .jvcm-match-badge-upcoming { color:#fbbf24;text-transform:none;letter-spacing:0;font-weight:500;font-size:0.65rem; }
            .jvcm-match-badge-done { color:var(--jvc-text-muted); }
            .jvcm-match-dot { width:6px;height:6px;border-radius:50%;background:currentColor;animation:jvcm-breathe 2.4s ease-in-out infinite; }
            .jvcm-match-stage { color:var(--jvc-text-muted);font-weight:500;font-size:0.6rem;text-transform:none;letter-spacing:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
            .jvcm-match-row-body { display:flex;align-items:baseline;gap:0.3rem;font-size:0.82rem;font-weight:700;color:var(--jvc-text); }
            .jvcm-match-team { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
            .jvcm-match-team:last-child { text-align:right; }
            .jvcm-match-team.winner { color:var(--jvc-green); }
            .jvcm-match-team.loser  { color:var(--jvc-text-muted); }
            .jvcm-match-score { flex-shrink:0;font-size:0.78rem;font-weight:800;padding:0 0.4rem;font-variant-numeric:tabular-nums; }
            .jvcm-match-vs { flex-shrink:0;font-size:0.65rem;color:var(--jvc-text-muted);padding:0 0.4rem; }
            .jvcm-match-games { display:flex;align-items:center;gap:0.25rem;margin-top:0.15rem;flex-wrap:wrap; }
            .jvcm-game-dot { width:22px;height:22px;border-radius:4px;font-size:0.62rem;font-weight:700;display:grid;place-items:center;background:var(--jvc-surface-3);color:var(--jvc-text-muted);cursor:default;transition:opacity 150ms ease; }
            .jvcm-game-dot.clickable { cursor:pointer;border:1px solid var(--jvc-border); }
            .jvcm-game-dot.clickable:active { opacity:0.7; }
            .jvcm-game-dot.selected { border-color:var(--jvc-orange);box-shadow:0 0 0 1px var(--jvc-orange); }
            .jvcm-game-dot.live  { background:rgba(239,68,68,0.2);color:#ef4444;animation:jvcm-breathe 2.4s ease-in-out infinite; }
            .jvcm-game-dot.win   { background:rgba(74,222,128,0.2);color:var(--jvc-green); }
            .jvcm-game-dot.loss  { background:rgba(248,113,113,0.15);color:var(--jvc-red); }
            .jvcm-game-dot.win-blue { background:rgba(100,149,237,0.25);color:#7fb4ff;border-color:rgba(100,149,237,0.4); }
            .jvcm-game-dot.win-red  { background:rgba(248,113,113,0.2);color:var(--jvc-red);border-color:rgba(248,113,113,0.4); }
            .jvcm-match-streams { display:flex;align-items:center;gap:0.35rem;margin-top:0.4rem;flex-wrap:wrap; }
            .jvcm-stream-btn { display:inline-flex;align-items:center;gap:0.3rem;padding:0.28rem 0.65rem;background:#9147ff;border:none;border-radius:4px;color:#fff;font-size:0.7rem;font-weight:600;font-family:inherit;cursor:pointer;text-decoration:none; }
            .jvcm-stream-btn:active { background:#7b35d9; }
            .jvcm-stream-select { padding:0.28rem 0.5rem;background:#9147ff;border:none;border-radius:4px;color:#fff;font-size:0.7rem;font-weight:600;font-family:inherit;cursor:pointer;-webkit-appearance:none;outline:none; }
            /* Stats game */
            .jvcm-game-stats { display:none;flex-direction:column;margin-top:0.35rem;background:rgba(255,255,255,0.03);border:1px solid var(--jvc-border);border-radius:6px;overflow:hidden; }
            .jvcm-game-stats.open { display:flex; }
            .jvcm-game-stats-header { padding:0.3rem 0.5rem;font-size:0.6rem;font-weight:700;color:var(--jvc-text-muted);text-transform:uppercase;letter-spacing:0.08em;border-bottom:1px solid var(--jvc-border); }
            .jvcm-game-stats-loading { padding:0.6rem;text-align:center;font-size:0.65rem;color:var(--jvc-text-muted); }
            /* Lane detail */
            .jvcm-lane-detail { display:none;flex-direction:column;border-top:1px solid rgba(239,68,68,0.15); }
            .jvcm-lane-detail.open { display:flex; }
            .jvcm-lane-row { display:flex;align-items:center;padding:0.35rem 0.5rem;gap:0.3rem;border-bottom:1px solid var(--jvc-divider);font-size:0.62rem;font-variant-numeric:tabular-nums; }
            .jvcm-lane-row:last-child { border-bottom:none; }
            .jvcm-lane-badge { width:20px;text-align:center;flex-shrink:0;font-size:0.6rem;color:var(--jvc-text-muted);font-weight:600; }
            .jvcm-lane-player { flex:1;min-width:0;display:flex;flex-direction:column;gap:0.05rem; }
            .jvcm-lane-player.right { align-items:flex-end;text-align:right; }
            .jvcm-lane-name { font-weight:600;color:var(--jvc-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px; }
            .jvcm-lane-champ { color:var(--jvc-text-muted);font-size:0.58rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px; }
            .jvcm-lane-kda { color:var(--jvc-text-dim);font-size:0.6rem;white-space:nowrap; }
            .jvcm-lane-gold { color:#fbbf24;font-size:0.6rem;font-weight:600; }
            .jvcm-lane-cs { color:var(--jvc-text-muted);font-size:0.58rem;font-style:italic; }
            .jvcm-lane-mid-col { display:flex;flex-direction:column;align-items:center;gap:0.1rem;flex-shrink:0;padding:0 0.2rem; }
            .jvcm-lane-level { font-size:0.6rem;color:var(--jvc-text-muted);background:var(--jvc-surface-3);border-radius:3px;padding:0.05rem 0.25rem; }
        `;
        document.head.appendChild(style);
    }

document.body.insertAdjacentHTML('beforeend', _matchesModalHTML());



    return setupMatchesUI();
}

// ─────────────────────────────────────────────────────────────
// BOOT
// ─────────────────────────────────────────────────────────────
function boot() {
    // Injecter Material Symbols pour l'icône JVChat
    if (!document.querySelector('link[href*="material-symbols"]')) {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&icon_names=chat';
        document.head.appendChild(link);
    }
    // Liste des topics : /forums/0-FORUMID-...
    // Page topic      : /forums/42-FORUMID-TOPICID-... ou /forums/1-...
    const isTopicPage = !!location.href.match(/\/forums\/(?:42|1)-\d+/);
    const lol = isLolContext();

    if (isTopicPage) {
        // ── Page topic : bouton JVChat + bouton 🏆 dans la navbar ──

        // Chercher le bouton Actualiser par classe OU par texte OU par icône
        let refreshIcon = document.querySelector('.buttonsNavbar .buttonsNavbar__icon.icon-refresh')
                       || document.querySelector('[class*="icon-refresh"]');
        if (!refreshIcon) {
            const allBtns = document.querySelectorAll('button, [class*="buttonsNavbar"] > *');
            for (const b of allBtns) {
                if (b.textContent.trim().includes('Actualiser') || b.querySelector('[class*="refresh"]')) {
                    refreshIcon = b; break;
                }
            }
        }
        if (!refreshIcon) {
            // Dernier fallback : chercher la navbar et prendre le dernier bouton
            const navbar2 = document.querySelector('.buttonsNavbar, [class*="buttonsNavbar"]');
            if (navbar2) refreshIcon = navbar2.lastElementChild;
        }
        if (!refreshIcon) return;

        // Bouton JVChat
        let matchesModal = null;

        // Créer une deuxième ligne sous la navbar JVC
        const navbar = refreshIcon.closest('.buttonsNavbar, [class*="navbar"], nav')
                    || refreshIcon.closest('div[class]')?.parentElement
                    || refreshIcon.parentElement;
        if (!navbar) return;

        const row2 = document.createElement('div');
        row2.id = 'jvcm-navbar-row2';
        row2.style.cssText = `
            display: flex; align-items: center; justify-content: center;
            background: ${getComputedStyle(navbar).backgroundColor || '#1a1a1f'};
            border-top: 1px solid rgba(255,255,255,0.08);
            padding: 4px 0;
            gap: 0;
        `;
        navbar.insertAdjacentElement('afterend', row2);

        const makeBtn = (icon, label, onClick) => {
            const btn = document.createElement('button');
            btn.style.cssText = `
                flex: 1; display: flex; flex-direction: column; align-items: center;
                justify-content: center; gap: 2px; padding: 6px 4px;
                background: none; border: none; cursor: pointer;
                color: inherit; font-family: inherit;
                -webkit-tap-highlight-color: transparent;
            `;
            btn.innerHTML = `<span style="font-size:1.1rem;line-height:1;">${icon}</span><span style="font-size:0.6rem;">${label}</span>`;
            btn.addEventListener('click', onClick);
            return btn;
        };

        const jvcBtn = makeBtn(
            '<span class="material-symbols-outlined" style="font-size:1.1rem;line-height:1;">chat</span>',
            'JVChat',
            () => {
                row2.remove();
                if (matchesModal) document.getElementById('jvcm-matches-overlay')?.remove();
                new JVChatMobile().start().catch(e => console.error('[JVChat Mobile]', e));
            }
        );
        row2.appendChild(jvcBtn);

        if (lol) {
            const sep = document.createElement('span');
            sep.style.cssText = 'width:1px;background:rgba(255,255,255,0.1);align-self:stretch;margin:4px 0;';
            row2.appendChild(sep);
            const matchBtn = makeBtn('🏆', 'Matchs', () => {
                if (!matchesModal) matchesModal = injectStandaloneMatchesModal();
                matchesModal.open();
            });
            row2.appendChild(matchBtn);
        }

    } else {
        // ── Liste des topics : bouton 🏆 dans la navbar du bas ──
        if (!lol) return;

        const injectIntoBottomNav = () => {
            const allBtns = document.querySelectorAll('.buttonsNavbar__button, [class*="navbar"] button, .forum-actions button');
            let refreshBtn = null;
            for (const b of allBtns) {
                if (b.textContent.includes('Actualiser') || b.querySelector('[class*="refresh"], [class*="icon-refresh"]')) {
                    refreshBtn = b; break;
                }
            }

            if (!refreshBtn) {
                if (document.getElementById('jvcm-float-btn')) return;
                injectStandaloneMatchesModal();
                const floatBtn = document.createElement('button');
                floatBtn.id = 'jvcm-float-btn';
                floatBtn.title = 'Matchs esport';
                floatBtn.textContent = '🏆';
                document.body.appendChild(floatBtn);
                let mm = null;
                floatBtn.addEventListener('click', () => {
                    if (!mm) mm = injectStandaloneMatchesModal();
                    mm.open();
                });
                return;
            }

            if (document.getElementById('jvcm-nav-matches-btn')) return;

            const matchBtn = document.createElement('button');
            matchBtn.id = 'jvcm-nav-matches-btn';
            matchBtn.type = 'button';
            matchBtn.className = refreshBtn.className;
            matchBtn.removeAttribute('style');
            const labelClass = refreshBtn.querySelector('div')?.className || '';
            matchBtn.innerHTML = `<i style="font-style:normal;animation:none;transform:none;">🏆</i><div class="${labelClass}">Matchs</div>`;

            // Insérer : Actualiser [spacer] [Matchs]
            const spBefore = document.createElement('span');
            spBefore.className = 'buttonsNavbar__space';
            refreshBtn.insertAdjacentElement('afterend', spBefore);
            spBefore.insertAdjacentElement('afterend', matchBtn);

            let mm = null;
            matchBtn.addEventListener('click', () => {
                if (!mm) mm = injectStandaloneMatchesModal();
                mm.open();
            });
        };

        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectIntoBottomNav, { once: true });
        else injectIntoBottomNav();
    }
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();