anime-sama Plus

Sauvegarde/restauration chiffrée du profil (.sama) + Next/Prev auto & contrôles clavier adaptatifs

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         anime-sama Plus
// @namespace    http://tampermonkey.net/
// @version      0.1.7.3
// @description  Sauvegarde/restauration chiffrée du profil (.sama) + Next/Prev auto & contrôles clavier adaptatifs
// @author       MASTERD
// @include      /^https?\:\/\/.*\.anime-sama\..*\/.*$/
// @include      /^https?\:\/\/.*\anime-sama\..*\/.*$/
// @match        *://*.callistanise.com/*
// @match        *://*.dingtezuni.com/*
// @match        *://*.embed4me.com/*
// @match        *://*.oneupload.to/*
// @match        *://*.oneupload.net/*
// @match        *://*.sendvid.com/*
// @match        *://*.sibnet.ru/*
// @match        *://*.smoothpre.com/*
// @match        *://*.vk.com/*
// @match        *://*.vkvideo.ru/*
// @include      /^https?\:\/\/.*vidmoly\..*\/.*$/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=anime-sama.org
// @grant        none
// ==/UserScript==

(function () {
    'use strict';
    // --------------------------------------------------------------------------
    //************** CONFIGURATION GÉNÉRALE **************/
    const PREFIX = 'ASP';
    const SCRIPT_CONFIG = {
        DEBUG: true,
        VERSION: '0.1.7.3',
        P_DOMAINS: ['anime-sama'],
        C_DOMAINS: ['sendvid.com', 'exemple.com']
    };
    //************** UTILITAIRES **************/
    const Utils = {
        log:   (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:LOG]`,     'color:#F47521;font-weight:bold', ...args),
        error: (...args) =>                        console.error(`%c[${PREFIX}:ERROR]`, 'color:red;font-weight:bold', ...args),
        warn:  (...args) => SCRIPT_CONFIG.DEBUG && console.warn(`%c[${PREFIX}:WARN]`,   'color:orange;font-weight:bold', ...args),
        info:  (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:INFO]`,    'color:#2196F3;font-weight:bold', ...args),
        skip:  (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:SKIP]`,    'color:#4CAF50;font-weight:bold', ...args),
        msg:   (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:MSG]`,     'color:#9C27B0;font-weight:bold', ...args),
        dom:   (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:DOM]`,     'color:#FF5722;font-weight:bold', ...args),
        key:   (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:KEY]`,     'color:#00BCD4;font-weight:bold', ...args),
        nav:   (...args) => SCRIPT_CONFIG.DEBUG && console.log(`%c[${PREFIX}:NAV]`,     'color:#795548;font-weight:bold', ...args),
        debounce: (func, delay) => {
            let timeout;
            return (...args) => {
                clearTimeout(timeout);
                timeout = setTimeout(() => func(...args), delay);
            };
        },
        waitForElement: (selector, timeout = 10000) => {
            return new Promise((resolve, reject) => {
                const el = document.querySelector(selector);
                if (el) return resolve(el);
                const observer = new MutationObserver((_, obs) => {
                    const found = document.querySelector(selector);
                    if (found) {
                        obs.disconnect();
                        resolve(found);
                    }
                });
                observer.observe(document.documentElement, { childList: true, subtree: true });
                setTimeout(() => { observer.disconnect(); reject(new Error(`Timeout: ${selector}`)); }, timeout);
            });
        },
        keyToSymbol: (key) => {
            const KEY_SYMBOLS = {
                ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
                Enter: '⏎', Escape: 'Esc', Tab: '⇥', Shift: '⇧',
                Control: 'Ctrl', Alt: 'Alt',
                Meta: navigator.platform.toUpperCase().includes('MAC') ? '⌘' : '❖',
                ' ': '────────', Space: '────────',
                Backspace: '⌫', Delete: '⌦', Insert: 'Ins',
                Home: 'Home', End: 'End', PageUp: 'Pg↑', PageDown: 'Pg↓',
                CapsLock: '⇪', Dead: '◌',
                AudioVolumeUp: '🔊', AudioVolumeDown: '🔉', AudioVolumeMute: '🔇',
                MediaPlayPause: '⏯', MediaTrackNext: '⏭', MediaTrackPrevious: '⏮'
            };
            if (KEY_SYMBOLS[key]) return KEY_SYMBOLS[key];
            if (key.length === 1 && key.match(/[a-z]/i)) return key.toUpperCase();
            return key;
        }
    };
    Utils.log(`[${PREFIX}] v${SCRIPT_CONFIG.VERSION} — init | origin: ${location.origin} | context: ${window.self === window.top ? 'TOP' : 'IFRAME'}`);

    // --------------------------------------------------------------------------
    // UI - Choix restauration (Remplacer/Annuler)
    function showChoiceDialog() {
        Utils.dom('showChoiceDialog → ouverture dialogue choix restauration');
        return new Promise(resolve => {
            const overlay = document.createElement('div');
            Object.assign(overlay.style, {
                position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)',
                display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000
            });
            const box = document.createElement('div');
            Object.assign(box.style, {
                background: '#111', color: '#fff', padding: '20px', borderRadius: '10px',
                width: 'min(92vw, 360px)', textAlign: 'center', fontFamily: 'sans-serif',
                boxShadow: '0 10px 30px rgba(0,0,0,.4)'
            });
            box.innerHTML = '<p style="margin-bottom:12px;font-weight:700">Comment voulez-vous restaurer&nbsp;?</p>';
            const mk = (label, code, bg) => {
                const b = document.createElement('button');
                b.textContent = label;
                Object.assign(b.style, {
                    margin: '0 8px', padding: '8px 12px', border: 'none', borderRadius: '6px',
                    cursor: 'pointer', fontWeight: 700, background: bg, color: '#fff'
                });
                b.onclick = () => {
                    Utils.dom(`showChoiceDialog → choix sélectionné : "${code}"`);
                    document.body.removeChild(overlay);
                    resolve(code);
                };
                return b;
            };
            box.appendChild(mk('Restaurer (Remplacer)', 'replace', '#e53935'));
            box.appendChild(mk('Annuler', 'cancel', '#555'));
            overlay.appendChild(box);
            document.body.appendChild(overlay);
            Utils.dom('showChoiceDialog → dialogue injecté dans le DOM');
        });
    }

    // --------------------------------------------------------------------------
    // UI - Mot de passe
    function showPasswordDialog(mode) {
        Utils.dom(`showPasswordDialog → ouverture dialogue mot de passe [mode: ${mode}]`);
        return new Promise(resolve => {
            const overlay = document.createElement('div');
            Object.assign(overlay.style, {
                position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.55)',
                display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10001
            });
            const box = document.createElement('div');
            Object.assign(box.style, {
                background: '#111', color: '#fff', padding: '24px', borderRadius: '12px',
                width: 'min(92vw, 380px)', fontFamily: 'sans-serif',
                boxShadow: '0 10px 30px rgba(0,0,0,.5)'
            });
            const title = mode === 'backup' ? 'Mot de passe de sauvegarde' : 'Mot de passe de restauration';
            box.innerHTML = `
            <p style="font-weight:700;margin-bottom:10px;text-align:center">${title}</p>
            <input id="asplus-pass" type="password" autocomplete="current-password"
                placeholder="(vide = SAMA)"
                style="width:100%;padding:8px;border-radius:6px;border:1px solid #333;background:#0b0b0b;color:#fff;margin-bottom:10px"/>
            <div id="asplus-risk-banner" style="
                display:block;margin:10px 0 12px 0;padding:10px 12px;border-radius:10px;
                border:2px solid #ff5252;background:linear-gradient(90deg,#3a0000,#180000);
                box-shadow:0 0 0 2px rgba(255,82,82,.25) inset, 0 0 18px rgba(255,82,82,.2);">
                <label for="asplus-remember" style="display:flex;gap:12px;align-items:flex-start;cursor:pointer;">
                    <input id="asplus-remember" type="checkbox" style="transform:scale(1.35);margin-top:2px"/>
                    <div>
                        <div style="color:#ff5252;font-weight:900;letter-spacing:.3px;text-transform:uppercase;font-size:14px;">
                            ⚠️ MÉMORISER (LOCAL SANS CHIFFREMENT)
                        </div>
                        <div style="color:#ffb3b3;font-size:12px;margin-top:2px;line-height:1.25;">
                            Le mot de passe sera stocké tel quel dans ce navigateur.
                            N'activez que si vous comprenez le risque.
                        </div>
                    </div>
                </label>
            </div>`;
            const btnRow = document.createElement('div');
            btnRow.style.cssText = 'display:flex;gap:10px;justify-content:center;margin-top:8px';
            const mkBtn = (label, bg, cb) => {
                const b = document.createElement('button');
                b.textContent = label;
                Object.assign(b.style, {
                    padding: '8px 16px', border: 'none', borderRadius: '6px',
                    cursor: 'pointer', fontWeight: 700, background: bg, color: '#fff'
                });
                b.onclick = cb;
                return b;
            };
            const submit = () => {
                const pass = box.querySelector('#asplus-pass').value;
                const remember = box.querySelector('#asplus-remember').checked;
                Utils.dom(`showPasswordDialog → soumission [remember: ${remember}, pass: ${pass.length ? '(défini)' : '(vide → SAMA)'}]`);
                document.body.removeChild(overlay);
                resolve({ pass, remember });
            };
            btnRow.appendChild(mkBtn('Valider', '#4caf50', submit));
            btnRow.appendChild(mkBtn('Annuler', '#555', () => {
                Utils.dom('showPasswordDialog → annulé par l\'utilisateur');
                document.body.removeChild(overlay);
                resolve({ pass: null, remember: false });
            }));
            box.appendChild(btnRow);
            overlay.appendChild(box);
            document.body.appendChild(overlay);
            Utils.dom('showPasswordDialog → dialogue injecté dans le DOM, focus sur le champ mot de passe');
            box.querySelector('#asplus-pass').addEventListener('keydown', e => {
                if (e.key === 'Enter') {
                    Utils.key('showPasswordDialog → touche Entrée détectée → soumission');
                    submit();
                }
            });
            box.querySelector('#asplus-pass').focus();
        });
    }
    async function getPassphrase(mode) {
        Utils.log(`getPassphrase → [mode: ${mode}]`);
        const sess = localStorage.getItem('asplus.passphrase');
        if (sess && sess.length) {
            Utils.info('getPassphrase → passphrase trouvée en localStorage (mémorisée), utilisation directe');
            return sess;
        }
        Utils.info('getPassphrase → aucune passphrase mémorisée, ouverture dialogue');
        const { pass, remember } = await showPasswordDialog(mode);
        if (pass === null) {
            Utils.warn('getPassphrase → dialogue annulé, utilisation de la passphrase par défaut "SAMA"');
        }
        const chosen = (pass && pass.length) ? pass : 'SAMA';
        if (remember) {
            Utils.warn('getPassphrase → passphrase mémorisée en localStorage (non chiffrée) ⚠️');
            localStorage.setItem('asplus.passphrase', chosen);
        }
        Utils.info(`getPassphrase → passphrase retournée : ${chosen === 'SAMA' ? '"SAMA" (défaut)' : '(personnalisée)'}`);
        return chosen;
    }

    // --------------------------------------------------------------------------
    // Sauvegarde / Restauration (AES-GCM 256)
    async function backupProfile() {
        Utils.log('backupProfile → début de la sauvegarde du profil');
        try {
            const data = {};
            for (let i = 0; i < localStorage.length; i++) {
                const key = localStorage.key(i);
                data[key] = localStorage.getItem(key);
            }
            Utils.info(`backupProfile → ${Object.keys(data).length} clé(s) localStorage collectée(s)`);
            const json = JSON.stringify(data);
            const encoder = new TextEncoder();
            const passphrase = await getPassphrase('backup');
            Utils.info('backupProfile → passphrase obtenue, génération IV aléatoire');
            const iv = crypto.getRandomValues(new Uint8Array(12));
            const baseKey = await crypto.subtle.importKey('raw', encoder.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']);
            Utils.info('backupProfile → clé PBKDF2 importée');
            const aesKey = await crypto.subtle.deriveKey(
                { name: 'PBKDF2', salt: iv, iterations: 100000, hash: 'SHA-256' },
                baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt']
            );
            Utils.info('backupProfile → clé AES-GCM 256 dérivée (100 000 itérations PBKDF2)');
            const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, encoder.encode(json));
            Utils.info(`backupProfile → chiffrement AES-GCM terminé [taille payload: ${12 + encrypted.byteLength} octets]`);
            const payload = new Uint8Array(iv.byteLength + encrypted.byteLength);
            payload.set(iv, 0);
            payload.set(new Uint8Array(encrypted), iv.byteLength);
            const blob = new Blob([payload], { type: 'application/vnd.animesama.backup' });
            Utils.info('backupProfile → Blob .sama créé, ouverture du sélecteur de fichier');
            await pickFileToSave(blob);
            Utils.log('backupProfile → sauvegarde terminée avec succès ✓');
        } catch (e) {
            Utils.error('backupProfile → échec de la sauvegarde :', e);
        }
    }
    async function pickFileToSave(blob) {
        Utils.log('pickFileToSave → tentative d\'enregistrement du fichier');
        if (window.showSaveFilePicker) {
            Utils.info('pickFileToSave → API showSaveFilePicker disponible, utilisation du sélecteur natif');
            const handle = await window.showSaveFilePicker({
                suggestedName: `anime-sama_${new Date().toISOString().slice(0, 10)}.sama`,
                types: [{ description: 'Backup Anime-Sama', accept: { 'application/vnd.animesama.backup': ['.sama'] } }]
            });
            const w = await handle.createWritable();
            await w.write(blob);
            await w.close();
            Utils.log('pickFileToSave → fichier écrit via FileSystemWritableFileStream ✓');
        } else {
            Utils.warn('pickFileToSave → showSaveFilePicker indisponible, fallback via <a download>');
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = `anime-sama_${new Date().toISOString().slice(0, 10)}.sama`;
            a.click();
            URL.revokeObjectURL(a.href);
            Utils.log('pickFileToSave → téléchargement déclenché via lien temporaire ✓');
        }
    }
    async function restoreProfile() {
        Utils.log('restoreProfile → début de la restauration du profil');
        try {
            let file;
            if (window.showOpenFilePicker) {
                Utils.info('restoreProfile → API showOpenFilePicker disponible, utilisation du sélecteur natif');
                const [handle] = await window.showOpenFilePicker({
                    types: [{ description: 'Backup Anime-Sama', accept: { 'application/vnd.animesama.backup': ['.sama'] } }]
                });
                file = await handle.getFile();
            } else {
                Utils.warn('restoreProfile → showOpenFilePicker indisponible, fallback via <input type="file">');
                file = await new Promise(resolve => {
                    const input = document.createElement('input');
                    input.type = 'file';
                    input.accept = '.sama';
                    input.onchange = () => resolve(input.files[0]);
                    input.click();
                });
            }
            if (!file) {
                Utils.warn('restoreProfile → aucun fichier sélectionné, abandon');
                return;
            }
            Utils.info(`restoreProfile → fichier sélectionné : "${file.name}" [${file.size} octets]`);
            const buf = await file.arrayBuffer();
            const arr = new Uint8Array(buf);
            const iv = arr.slice(0, 12);
            const encrypted = arr.slice(12);
            Utils.info(`restoreProfile → IV extrait (12 octets), données chiffrées : ${encrypted.byteLength} octets`);
            const passphrase = await getPassphrase('restore');
            Utils.info('restoreProfile → passphrase obtenue, dérivation de la clé AES-GCM');
            const encoder = new TextEncoder();
            const baseKey = await crypto.subtle.importKey('raw', encoder.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']);
            const aesKey = await crypto.subtle.deriveKey(
                { name: 'PBKDF2', salt: iv, iterations: 100000, hash: 'SHA-256' },
                baseKey, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
            );
            Utils.info('restoreProfile → clé AES-GCM 256 dérivée, tentative de déchiffrement');
            const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, encrypted);
            const json = new TextDecoder().decode(decrypted);
            const data = JSON.parse(json);
            Utils.info(`restoreProfile → déchiffrement réussi ✓ [${Object.keys(data).length} clé(s) à restaurer]`);
            const choice = await showChoiceDialog();
            if (choice === 'cancel') {
                Utils.warn('restoreProfile → restauration annulée par l\'utilisateur');
                return;
            }
            if (choice === 'replace') {
                Utils.warn('restoreProfile → mode "replace" → effacement du localStorage courant');
                localStorage.clear();
            }
            for (const [k, v] of Object.entries(data)) localStorage.setItem(k, v);
            Utils.log(`restoreProfile → ${Object.keys(data).length} entrée(s) restaurée(s) dans localStorage ✓`);
            Utils.log('restoreProfile → rechargement de la page...');
            location.reload();
        } catch (e) {
            Utils.error('restoreProfile → échec de la restauration :', e);
            alert('Échec de la restauration. Vérifiez le mot de passe.');
        }
    }

    // --------------------------------------------------------------------------
    // Profil dropdown (anime-sama uniquement)
    function createProfileDropdown() {
        Utils.dom('createProfileDropdown → tentative de création du dropdown profil');
        const nav = document.querySelector('.asn-nav-desktop');
        if (!nav) {
            Utils.skip('createProfileDropdown → .asn-nav-desktop introuvable, abandon');
            return;
        }
        if (document.getElementById('tampered-dropdown')) {
            Utils.skip('createProfileDropdown → #tampered-dropdown déjà présent, abandon');
            return;
        }

        const profileLink = nav.querySelector('a[href="/profil"]');
        if (!profileLink) {
            Utils.skip('createProfileDropdown → lien /profil introuvable dans la nav, abandon');
            return;
        }
        Utils.dom('createProfileDropdown → lien /profil trouvé, construction du wrapper');

        // Créer le wrapper
        const wrapper = document.createElement('div');
        wrapper.id = 'tampered-dropdown';
        wrapper.className = 'relative inline-block text-left';

        // Créer le bouton en copiant le contenu du lien Profil
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = profileLink.className + ' inline-flex items-center cursor-pointer';
        btn.innerHTML = profileLink.innerHTML;
        btn.insertAdjacentHTML('beforeend', `
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-1 text-white transform transition-transform duration-200" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.293l3.71-4.06a.75.75 0 111.08 1.04l-4.25 4.656a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z" clip-rule="evenodd"/>
        </svg>`);
        wrapper.appendChild(btn);
        Utils.dom('createProfileDropdown → bouton principal créé avec flèche SVG');

        // Menu dropdown
        const menu = document.createElement('div');
        menu.className = 'absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50';
        menu.style.display = 'none';

        const mkItem = (icon, label, action) => {
            const a = document.createElement('a');
            a.href = '#';
            a.className = 'block px-4 py-2 text-sm text-white hover:bg-gray-700';
            a.textContent = `${icon} ${label}`;
            a.addEventListener('click', (e) => {
                e.preventDefault();
                Utils.dom(`createProfileDropdown → item cliqué : "${label}"`);
                menu.style.display = 'none';
                action();
            });
            return a;
        };

        // Lien vers profil original
        const profItem = document.createElement('a');
        profItem.href = '/profil';
        profItem.className = 'block px-4 py-2 text-sm text-white hover:bg-gray-700';
        profItem.textContent = '👤 Profil';
        menu.appendChild(profItem);

        menu.appendChild(mkItem('💾', 'Sauvegarder', backupProfile));
        menu.appendChild(mkItem('📂', 'Restaurer', restoreProfile));
        wrapper.appendChild(menu);
        Utils.dom('createProfileDropdown → menu dropdown construit (3 items : Profil, Sauvegarder, Restaurer)');

        // Toggle
        const arrow = btn.querySelector('svg:last-child');
        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            const open = menu.style.display !== 'none';
            menu.style.display = open ? 'none' : 'block';
            if (arrow) arrow.style.transform = open ? '' : 'rotate(180deg)';
            Utils.dom(`createProfileDropdown → toggle menu → ${open ? 'fermé' : 'ouvert'}`);
        });
        document.addEventListener('click', (e) => {
            if (!wrapper.contains(e.target)) {
                menu.style.display = 'none';
                if (arrow) arrow.style.transform = '';
            }
        });

        // Remplacer le lien par le wrapper
        profileLink.replaceWith(wrapper);
        Utils.dom('createProfileDropdown → lien /profil remplacé par le wrapper dropdown ✓');
    }
    function ensureProfileDropdown() {
        const nav = document.querySelector('.asn-nav-desktop');
        if (nav && !document.querySelector('#tampered-dropdown')) {
            Utils.dom('ensureProfileDropdown → nav présente et dropdown absent → appel createProfileDropdown');
            createProfileDropdown();
        } else if (!nav) {
            Utils.skip('ensureProfileDropdown → .asn-nav-desktop absent, rien à faire');
        } else {
            Utils.skip('ensureProfileDropdown → dropdown déjà présent, rien à faire');
        }
    }

    let _ensureTimer = null;
    function scheduleEnsure() {
        if (_ensureTimer) return;
        _ensureTimer = setTimeout(() => {
            _ensureTimer = null;
            Utils.dom('scheduleEnsure → délai écoulé → appel ensureProfileDropdown');
            ensureProfileDropdown();
        }, 100);
    }

    if (document.readyState !== 'loading') {
        Utils.dom(`createProfileDropdown → document déjà chargé (readyState: "${document.readyState}") → appel immédiat`);
        ensureProfileDropdown();
    } else {
        Utils.dom('createProfileDropdown → document en cours de chargement → attente DOMContentLoaded');
        window.addEventListener('DOMContentLoaded', () => {
            Utils.dom('createProfileDropdown → DOMContentLoaded reçu → appel ensureProfileDropdown');
            ensureProfileDropdown();
        });
    }

    const domObserver = new MutationObserver(scheduleEnsure);
    domObserver.observe(document.documentElement, { childList: true, subtree: true });
    Utils.dom('createProfileDropdown → MutationObserver actif sur document.documentElement');

    (function hookHistory() {
        Utils.dom('hookHistory → injection des hooks pushState / replaceState / popstate');
        const fire = () => {
            Utils.nav('hookHistory → navigation détectée → dispatch asplus:navigation');
            window.dispatchEvent(new Event('asplus:navigation'));
        };
        const _push = history.pushState, _replace = history.replaceState;
        history.pushState = function (...a) {
            const r = _push.apply(this, a);
            Utils.nav(`hookHistory → pushState intercepté → url: ${a[2] ?? '(aucune)'}`);
            fire();
            return r;
        };
        history.replaceState = function (...a) {
            const r = _replace.apply(this, a);
            Utils.nav(`hookHistory → replaceState intercepté → url: ${a[2] ?? '(aucune)'}`);
            fire();
            return r;
        };
        window.addEventListener('popstate', () => {
            Utils.nav('hookHistory → popstate détecté');
            fire();
        });
        window.addEventListener('asplus:navigation', () => {
            Utils.nav('hookHistory → asplus:navigation reçu → scheduleEnsure');
            scheduleEnsure();
        });
        Utils.dom('hookHistory → hooks history installés ✓');
    })();

    document.addEventListener('visibilitychange', () => {
        if (!document.hidden) {
            Utils.dom('visibilitychange → page redevenue visible → scheduleEnsure');
            scheduleEnsure();
        }
    });

    // --------------------------------------------------------------------------
    // Réactiver la sélection de texte
    (function enableSelection() {
        Utils.dom('enableSelection → injection du CSS user-select:text global');
        const css = `html, body, * {
        -webkit-user-select: text !important;
        -moz-user-select: text !important;
        -ms-user-select: text !important;
        user-select: text !important;
        -webkit-touch-callout: default !important;
    }`;
        const style = document.createElement('style');
        style.id = 'asplus-enable-selection';
        style.appendChild(document.createTextNode(css));
        (document.head || document.documentElement).appendChild(style);
        Utils.dom('enableSelection → balise <style id="asplus-enable-selection"> injectée ✓');

        const BLOCKED_EVENTS = ['copy', 'cut', 'paste', 'contextmenu', 'selectstart', 'dragstart'];
        const unblock = e => { e.stopImmediatePropagation(); };
        BLOCKED_EVENTS.forEach(t => document.addEventListener(t, unblock, true));
        Utils.dom(`enableSelection → stopImmediatePropagation activé sur : [${BLOCKED_EVENTS.join(', ')}]`);

        const fixInline = el => {
            if (!el || !el.style) return;
            el.style.setProperty('user-select', 'text', 'important');
            el.style.setProperty('-webkit-user-select', 'text', 'important');
            el.style.setProperty('-moz-user-select', 'text', 'important');
            el.style.setProperty('-ms-user-select', 'text', 'important');
            el.style.setProperty('-webkit-touch-callout', 'default', 'important');
        };

        fixInline(document.body);
        Utils.dom('enableSelection → fixInline appliqué sur document.body');

        new MutationObserver(muts => {
            for (const m of muts) {
                if (m.type === 'attributes' && m.attributeName === 'style') {
                    Utils.dom(`enableSelection → MutationObserver → style modifié sur <${m.target.tagName?.toLowerCase()}>, correction inline`);
                    fixInline(m.target);
                }
                if (m.addedNodes) {
                    m.addedNodes.forEach(n => {
                        if (n.nodeType === 1) {
                            Utils.dom(`enableSelection → MutationObserver → nœud ajouté <${n.tagName?.toLowerCase()}>, fixInline appliqué`);
                            fixInline(n);
                        }
                    });
                }
            }
        }).observe(document.documentElement, {
            childList: true, subtree: true,
            attributes: true, attributeFilter: ['style']
        });
        Utils.dom('enableSelection → MutationObserver actif (style + addedNodes) ✓');
    })();

    // --------------------------------------------------------------------------
    //************** INJECTION LECTEUR (parent/iframe) + auto-next + raccourcis **************/
    const injectedCode =`
        (function () {
            //************** LOGGER (miroir de Utils) **************/
            var _DEBUG = ${SCRIPT_CONFIG.DEBUG};
            var _log   = function() { if (!_DEBUG) return; var a = ['%c[ASP:LOG]',  'color:#F47521;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _warn  = function() { if (!_DEBUG) return; var a = ['%c[ASP:WARN]', 'color:orange;font-weight:bold'].concat(Array.prototype.slice.call(arguments));  console.warn.apply(console, a); };
            var _err   = function() {                      var a = ['%c[ASP:ERROR]','color:red;font-weight:bold'].concat(Array.prototype.slice.call(arguments));     console.error.apply(console, a); };
            var _info  = function() { if (!_DEBUG) return; var a = ['%c[ASP:INFO]', 'color:#2196F3;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _dom   = function() { if (!_DEBUG) return; var a = ['%c[ASP:DOM]',  'color:#FF5722;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _msg   = function() { if (!_DEBUG) return; var a = ['%c[ASP:MSG]',  'color:#9C27B0;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _skip  = function() { if (!_DEBUG) return; var a = ['%c[ASP:SKIP]', 'color:#4CAF50;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _key   = function() { if (!_DEBUG) return; var a = ['%c[ASP:KEY]',  'color:#00BCD4;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };
            var _nav   = function() { if (!_DEBUG) return; var a = ['%c[ASP:NAV]',  'color:#CDDC39;font-weight:bold'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, a); };

            _log('[init] → script injecté démarré', { version: '${SCRIPT_CONFIG.VERSION}', href: location.href });

            var CONTROL_DOMAINS = ${JSON.stringify(SCRIPT_CONFIG.C_DOMAINS)};
            var PARENT_DOMAINS  = ${JSON.stringify(SCRIPT_CONFIG.P_DOMAINS)};
            var SITE  = location.hostname;
            var isTop = (window.self === window.top);

            _info('[init] → domaines chargés', { CONTROL_DOMAINS: CONTROL_DOMAINS, PARENT_DOMAINS: PARENT_DOMAINS });

            function matchHost(host, pattern) {
                if (!host || !pattern) return false;
                if (pattern.indexOf('.') !== -1) {
                    return host === pattern || host.endsWith('.' + pattern);
                }
                var esc = pattern.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
                return new RegExp('(?:^|\\\\.)' + esc + '\\\\.', 'i').test(host);
            }

            var isControlHost = CONTROL_DOMAINS.some(function(p){ return matchHost(SITE, p); });
            var isParentHost  = PARENT_DOMAINS.some(function(p){  return matchHost(SITE, p); });

            _info('[init] → détection hôte', { SITE: SITE, isControlHost: isControlHost, isParentHost: isParentHost, isTop: isTop });

            var ref     = document.referrer || '';
            var refHost = '';
            try {
                refHost = new URL(ref).hostname;
                _info('[init] → referrer parsé', { ref: ref, refHost: refHost });
            } catch (_) {
                _warn('[init] → referrer invalide ou absent', { ref: ref });
            }

            var refIsParent     = PARENT_DOMAINS.some(function(p){ return matchHost(refHost, p); });
            var fromAnimeParent = isTop && (isParentHost || !!document.getElementById('playerDF'));
            var fromAnimeIframe = !isTop && refIsParent;

            _log('[init] → contexte déterminé', {
                host:           SITE,
                isTop:          isTop,
                fromAnimeParent:fromAnimeParent,
                fromAnimeIframe:fromAnimeIframe,
                refHost:        refHost,
                refIsParent:    refIsParent,
                playerDFPresent:!!document.getElementById('playerDF')
            });

            if (!fromAnimeParent && !fromAnimeIframe && !isControlHost) {
                _skip('[init] → contexte non reconnu (ni parent, ni iframe, ni controlHost) → sortie');
                return;
            }

            // ── pendingToggle ─────────────────────────────────────────────────────────
            var pendingToggle = false;
            try {
                if (sessionStorage.getItem('asp_pendingToggle') === '1') {
                    pendingToggle = true;
                    sessionStorage.removeItem('asp_pendingToggle');
                    _log('[parent] → pendingToggle restauré depuis sessionStorage ✓');
                } else {
                    _info('[parent] → asp_pendingToggle absent en sessionStorage');
                }
            } catch(e) {
                _warn('[parent] → échec lecture sessionStorage asp_pendingToggle :', e);
            }

            // ── prevEp / nextEp ───────────────────────────────────────────────────────
            var prevEp = window.prevEp || function() { _warn('[nav] → prevEp non défini sur window'); };
            var nextEp = window.nextEp || function() { _warn('[nav] → nextEp non défini sur window'); };

            if (window.prevEp) {
                _info('[nav] → window.prevEp trouvé ✓');
            } else {
                _warn('[nav] → window.prevEp absent → fallback no-op');
            }
            if (window.nextEp) {
                _info('[nav] → window.nextEp trouvé ✓');
            } else {
                _warn('[nav] → window.nextEp absent → fallback no-op');
            }

            var EPS = 3;
            _info('[init] → EPS (seuil fin de vidéo) défini à', EPS, 'secondes');

            //**** PARENT: raccourcis clavier ****
            function parentKeyHandler(e) {
                if (/input|textarea|select/i.test(e.target.tagName)) {
                    _skip('[parent:key] → cible est un champ de saisie, ignoré :', e.target.tagName);
                    return;
                }
                var iframe = document.getElementById('playerDF');

                // Touches navigation : toujours actives
                switch (e.key) {
                    case 'n': case 'N':
                        e.preventDefault();
                        _key('[parent:key] → "N" → nextEp, pendingToggle=true, sessionStorage mis à jour');
                        pendingToggle = true;
                        try { sessionStorage.setItem('asp_pendingToggle', '1'); } catch(err) { _warn('[parent:key] → échec sessionStorage setItem :', err); }
                        nextEp();
                        return;
                    case 'p': case 'P':
                        e.preventDefault();
                        _key('[parent:key] → "P" → prevEp, pendingToggle=true, sessionStorage mis à jour');
                        pendingToggle = true;
                        try { sessionStorage.setItem('asp_pendingToggle', '1'); } catch(err) { _warn('[parent:key] → échec sessionStorage setItem :', err); }
                        prevEp();
                        return;
                }

                // Touches de contrôle : soumises à CONTROL_DOMAINS
                if (!isControlHost) {
                    _skip('[parent:key] → touche "' + e.key + '" ignorée (hôte non controlHost) :', SITE);
                    return;
                }
                if (!iframe || !iframe.contentWindow) {
                    _warn('[parent:key] → #playerDF introuvable ou sans contentWindow, postMessage impossible');
                }

                switch (e.key) {
                    case ' ':
                        e.preventDefault();
                        _key('[parent:key] → Espace → togglePlay');
                        pendingToggle = true;
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'togglePlay' }, '*');
                        break;
                    case 'ArrowRight':
                        e.preventDefault();
                        _key('[parent:key] → ArrowRight → seekForward +10s');
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'seekForward', value: 10 }, '*');
                        break;
                    case 'ArrowLeft':
                        e.preventDefault();
                        _key('[parent:key] → ArrowLeft → seekBackward -10s');
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'seekBackward', value: 10 }, '*');
                        break;
                    case 'ArrowUp':
                        e.preventDefault();
                        _key('[parent:key] → ArrowUp → volumeUp +10');
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'volumeUp', value: 10 }, '*');
                        break;
                    case 'ArrowDown':
                        e.preventDefault();
                        _key('[parent:key] → ArrowDown → volumeDown -10');
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'volumeDown', value: 10 }, '*');
                        break;
                    case 'f': case 'F':
                        e.preventDefault();
                        _key('[parent:key] → "F" → toggleFullscreen');
                        if (iframe && iframe.contentWindow)
                            iframe.contentWindow.postMessage({ action: 'toggleFullscreen' }, '*');
                        break;
                    default:
                        _skip('[parent:key] → touche "' + e.key + '" non gérée');
                }
            }

            //**** PARENT: message handler ****
            function messageHandler(e) {
                if (!e.data || !e.data.action) {
                    _skip('[parent:msg] → message reçu sans action, ignoré', e.data);
                    return;
                }
                _msg('[parent:msg] → action reçue :', e.data.action, '| origin :', e.origin);
                switch (e.data.action) {
                    case 'Istart': {
                        _msg('[parent:msg] → Istart reçu | pendingToggle =', pendingToggle);
                        if (pendingToggle) {
                            pendingToggle = false;
                            var iframe = document.getElementById('playerDF');
                            if (iframe && iframe.contentWindow) {
                                _msg('[parent:msg] → Istart → focus iframe + postMessage togglePlay + focusPlayer');
                                iframe.contentWindow.postMessage({ action: 'togglePlay' }, '*');
                                iframe.contentWindow.postMessage({ action: 'focusPlayer' }, '*');
                            } else {
                                _warn('[parent:msg] → Istart → #playerDF introuvable, togglePlay annulé');
                            }
                        } else {
                            _skip('[parent:msg] → Istart → pendingToggle=false, togglePlay ignoré');
                        }
                        break;
                    }
                    case 'nextEp': {
                        _nav('[parent:msg] → nextEp reçu → pendingToggle=true, sessionStorage, nextEp()');
                        pendingToggle = true;
                        try { sessionStorage.setItem('asp_pendingToggle', '1'); } catch(err) { _warn('[parent:msg] → échec sessionStorage :', err); }
                        nextEp();
                        break;
                    }
                    case 'prevEp': {
                        _nav('[parent:msg] → prevEp reçu → pendingToggle=true, sessionStorage, prevEp()');
                        pendingToggle = true;
                        try { sessionStorage.setItem('asp_pendingToggle', '1'); } catch(err) { _warn('[parent:msg] → échec sessionStorage :', err); }
                        prevEp();
                        break;
                    }
                    default: {
                        _skip('[parent:msg] → action non gérée :', e.data.action);
                    }
                }
            }

            //**** IFRAME (<video> natif): raccourcis clavier ****
            function iframeKeyHandler(e) {
                if (/input|textarea|select/i.test(e.target.tagName)) {
                    _skip('[iframe:key] → cible est un champ de saisie, ignoré :', e.target.tagName);
                    return;
                }
                var v = document.querySelector('video');

                // Touches navigation : toujours actives
                switch (e.key) {
                    case 'n': case 'N': {
                        e.preventDefault();
                        _key('[iframe:key] → "N" → postMessage nextEp → parent');
                        window.parent.postMessage({ action: 'nextEp' }, '*');
                        return;
                    }
                    case 'p': case 'P': {
                        e.preventDefault();
                        _key('[iframe:key] → "P" → postMessage prevEp → parent');
                        window.parent.postMessage({ action: 'prevEp' }, '*');
                        return;
                    }
                }

                // Touches de contrôle : soumises à CONTROL_DOMAINS
                if (!isControlHost) {
                    _skip('[iframe:key] → touche "' + e.key + '" ignorée (hôte non controlHost) :', SITE);
                    return;
                }
                if (!v) {
                    _warn('[iframe:key] → aucun élément <video> trouvé, contrôle impossible');
                    return;
                }

                switch (e.key) {
                    case ' ': {
                        e.preventDefault();
                        _key('[iframe:key] → Espace → togglePlay');
                        if (v.paused) {
                            v.play().catch(function(err) { _warn('[iframe:key] → play() rejeté :', err); });
                        } else {
                            v.pause();
                        }
                        break;
                    }
                    case 'ArrowRight': {
                        e.preventDefault();
                        _key('[iframe:key] → ArrowRight → seekForward +10s |', v.currentTime.toFixed(1), '→', Math.min(v.duration, v.currentTime + 10).toFixed(1));
                        v.currentTime = Math.min(v.duration, v.currentTime + 10);
                        break;
                    }
                    case 'ArrowLeft': {
                        e.preventDefault();
                        _key('[iframe:key] → ArrowLeft → seekBackward -10s |', v.currentTime.toFixed(1), '→', Math.max(0, v.currentTime - 10).toFixed(1));
                        v.currentTime = Math.max(0, v.currentTime - 10);
                        break;
                    }
                    case 'ArrowUp': {
                        e.preventDefault();
                        _key('[iframe:key] → ArrowUp → volumeUp +0.1 |', v.volume.toFixed(2), '→', Math.min(1, v.volume + 0.1).toFixed(2));
                        v.volume = Math.min(1, v.volume + 0.1);
                        break;
                    }
                    case 'ArrowDown': {
                        e.preventDefault();
                        _key('[iframe:key] → ArrowDown → volumeDown -0.1 |', v.volume.toFixed(2), '→', Math.max(0, v.volume - 0.1).toFixed(2));
                        v.volume = Math.max(0, v.volume - 0.1);
                        break;
                    }
                    case 'f': case 'F': {
                        e.preventDefault();
                        if (document.fullscreenElement) {
                            _key('[iframe:key] → "F" → exitFullscreen');
                            document.exitFullscreen();
                        } else if (v.requestFullscreen) {
                            _key('[iframe:key] → "F" → requestFullscreen');
                            v.requestFullscreen();
                        } else {
                            _warn('[iframe:key] → "F" → requestFullscreen non supporté');
                        }
                        break;
                    }
                    default: {
                        _skip('[iframe:key] → touche "' + e.key + '" non gérée');
                    }
                }
            }

            //**** IFRAME: toggle play/pause ****
            function togglePlayPauseAfterDelay() {
                _dom('[iframe:togglePlay] → setTimeout 300ms avant toggle');
                setTimeout(function() {
                    var v = document.querySelector('video');
                    if (!v) {
                        _warn('[iframe:togglePlay] → aucun <video> trouvé après délai');
                        return;
                    }
                    if (v.paused) {
                        _dom('[iframe:togglePlay] → vidéo en pause → play()');
                        v.play().catch(function(err) { _warn('[iframe:togglePlay] → play() rejeté :', err); });
                    } else {
                        _dom('[iframe:togglePlay] → vidéo en lecture → pause()');
                        v.pause();
                    }
                }, 300);
            }

            //**** IFRAME (<video> natif): détection fin ****
            function addVideoEndDetectors() {
                _dom('[iframe:endDetect] → initialisation des détecteurs de fin de vidéo');
                var sent = false;

                function sendNext(src) {
                    if (sent) {
                        _skip('[iframe:endDetect] → sendNext déjà envoyé, doublon ignoré (src:', src, ')');
                        return;
                    }
                    sent = true;
                    _log('[iframe:endDetect] → nextEp envoyé via :', src);
                    window.parent.postMessage({ action: 'nextEp' }, '*');
                }

                function attachToVideo() {
                    var v = document.querySelector('video');
                    if (!v) {
                        _warn('[iframe:endDetect] → attachToVideo appelé mais aucun <video> trouvé');
                        return;
                    }
                    _dom('[iframe:endDetect] → <video> trouvé, attache événement "ended" + stallTimer');

                    v.addEventListener('ended', function() {
                        _log('[iframe:endDetect] → événement "ended" déclenché');
                        sendNext('ended');
                    });

                    var lastT = -1;
                    var stallTimer = setInterval(function() {
                        if (sent) {
                            _skip('[iframe:endDetect] → stallTimer → nextEp déjà envoyé, clearInterval');
                            clearInterval(stallTimer);
                            return;
                        }
                        var d = v.duration;
                        if (!isFinite(d) || !d) return;
                        var now = v.currentTime;
                        var remaining = d - now;
                        if (now === lastT && remaining <= EPS && v.paused) {
                            _log('[iframe:endDetect] → stallTimer → stall détecté en fin de vidéo',
                                '| currentTime:', now.toFixed(2), '| remaining:', remaining.toFixed(2), '| EPS:', EPS);
                            sendNext('stall-end');
                            clearInterval(stallTimer);
                        }
                        lastT = now;
                    }, 1000);
                }

                if (document.querySelector('video')) {
                    _dom('[iframe:endDetect] → <video> présent immédiatement → attachToVideo()');
                    attachToVideo();
                } else {
                    _dom('[iframe:endDetect] → <video> absent → MutationObserver en attente');
                    var obs = new MutationObserver(function() {
                        if (document.querySelector('video')) {
                            _dom('[iframe:endDetect] → MutationObserver → <video> détecté → attachToVideo()');
                            obs.disconnect();
                            attachToVideo();
                        }
                    });
                    obs.observe(document.body, { childList: true, subtree: true });
                }
            }

            //**********************
            //* IFRAME JW PLAYER (VidMoly, etc.)
            //**********************
            function detectJWPlayer() {
                try {
                    var p = jwplayer();
                    var ok = (p && typeof p.getState === 'function');
                    if (ok) {
                        _dom('[JW:detect] → jwplayer() trouvé et opérationnel | state:', p.getState());
                    } else {
                        _skip('[JW:detect] → jwplayer() présent mais getState absent');
                    }
                    return ok ? p : null;
                } catch (err) {
                    _skip('[JW:detect] → jwplayer() non disponible :', err.message);
                    return null;
                }
            }

            function waitForJWPlayer(timeout) {
                timeout = timeout || 10000;
                _dom('[JW:wait] → démarrage polling JWPlayer | timeout:', timeout, 'ms');
                return new Promise(function(resolve, reject) {
                    var t0 = Date.now();
                    var attempts = 0;
                    var check = setInterval(function() {
                        attempts++;
                        var p = detectJWPlayer();
                        if (p) {
                            _dom('[JW:wait] → JWPlayer détecté après', attempts, 'tentative(s) |', (Date.now() - t0), 'ms');
                            clearInterval(check);
                            resolve(p);
                            return;
                        }
                        if (Date.now() - t0 > timeout) {
                            _warn('[JW:wait] → timeout atteint après', attempts, 'tentatives |', timeout, 'ms');
                            clearInterval(check);
                            reject(new Error('JW timeout'));
                        }
                    }, 300);
                });
            }

            function forcePlayJW(player) {
                var state = player.getState();
                _dom('[JW:forcePlay] → état actuel :', state);

                var overlays = [
                    '.jw-display-icon-container',
                    '.jw-icon-display',
                    '.jw-controls .jw-icon-playback',
                    '.vjs-big-play-button'
                ];
                var clicked = false;
                for (var i = 0; i < overlays.length; i++) {
                    var el = document.querySelector(overlays[i]);
                    if (el) {
                        _dom('[JW:forcePlay] → click overlay trouvé :', overlays[i]);
                        el.click();
                        clicked = true;
                        break;
                    }
                }
                if (!clicked) {
                    _skip('[JW:forcePlay] → aucun overlay cliquable trouvé');
                }

                try {
                    player.play();
                    _dom('[JW:forcePlay] → player.play() appelé');
                } catch(e) {
                    _err('[JW:forcePlay] → player.play() échoué :', e);
                }

                setTimeout(function() {
                    var s = player.getState();
                    if (s !== 'playing') {
                        _warn('[JW:forcePlay] → retry #1 après 500ms | state:', s);
                        try { player.play(); } catch(e) { _warn('[JW:forcePlay] → retry #1 play() échoué :', e); }
                    } else {
                        _dom('[JW:forcePlay] → retry #1 inutile, déjà en lecture ✓');
                    }
                }, 500);

                setTimeout(function() {
                    var s = player.getState();
                    if (s !== 'playing') {
                        _warn('[JW:forcePlay] → retry #2 après 1500ms + fallback <video> | state:', s);
                        try { player.play(); } catch(e) { _warn('[JW:forcePlay] → retry #2 play() échoué :', e); }
                        var v = document.querySelector('video');
                        if (v && v.paused) {
                            _dom('[JW:forcePlay] → fallback <video>.play()');
                            v.play().catch(function(err) { _warn('[JW:forcePlay] → fallback <video>.play() rejeté :', err); });
                        } else if (!v) {
                            _warn('[JW:forcePlay] → fallback : aucun <video> trouvé');
                        } else {
                            _skip('[JW:forcePlay] → fallback : <video> déjà en lecture');
                        }
                    } else {
                        _dom('[JW:forcePlay] → retry #2 inutile, déjà en lecture ✓');
                    }
                }, 1500);
            }

            function jwKeyHandler(player, e) {
                if (/input|textarea|select/i.test(e.target.tagName)) {
                    _skip('[JW:key] → cible champ de saisie, ignoré :', e.target.tagName);
                    return;
                }

                // Touches navigation : toujours actives
                switch (e.key) {
                    case 'n': case 'N':
                        e.preventDefault();
                        _key('[JW:key] → "N" → postMessage nextEp → parent');
                        window.parent.postMessage({ action: 'nextEp' }, '*');
                        return;
                    case 'p': case 'P':
                        e.preventDefault();
                        _key('[JW:key] → "P" → postMessage prevEp → parent');
                        window.parent.postMessage({ action: 'prevEp' }, '*');
                        return;
                }

                // Touches de contrôle : soumises à CONTROL_DOMAINS
                if (!isControlHost) {
                    _skip('[JW:key] → touche "' + e.key + '" ignorée (hôte non controlHost) :', SITE);
                    return;
                }

                var pos, vol;
                switch (e.key) {
                    case ' ':
                        e.preventDefault();
                        var st = player.getState();
                        _key('[JW:key] → Espace → togglePlay | state:', st);
                        st === 'playing' ? player.pause() : player.play();
                        break;
                    case 'ArrowRight':
                        e.preventDefault();
                        pos = player.getPosition();
                        _key('[JW:key] → ArrowRight → seekForward +10s |', pos.toFixed(1), '→', (pos + 10).toFixed(1));
                        player.seek(pos + 10);
                        break;
                    case 'ArrowLeft':
                        e.preventDefault();
                        pos = player.getPosition();
                        _key('[JW:key] → ArrowLeft → seekBackward -10s |', pos.toFixed(1), '→', Math.max(0, pos - 10).toFixed(1));
                        player.seek(Math.max(0, pos - 10));
                        break;
                    case 'ArrowUp':
                        e.preventDefault();
                        vol = player.getVolume();
                        _key('[JW:key] → ArrowUp → volumeUp +10 |', vol, '→', Math.min(100, vol + 10));
                        player.setVolume(Math.min(100, vol + 10));
                        break;
                    case 'ArrowDown':
                        e.preventDefault();
                        vol = player.getVolume();
                        _key('[JW:key] → ArrowDown → volumeDown -10 |', vol, '→', Math.max(0, vol - 10));
                        player.setVolume(Math.max(0, vol - 10));
                        break;
                    case 'f': case 'F':
                        e.preventDefault();
                        var fs = player.getFullscreen();
                        _key('[JW:key] → "F" → toggleFullscreen |', fs ? 'fullscreen → normal' : 'normal → fullscreen');
                        player.setFullscreen(!fs);
                        break;
                    default:
                        _skip('[JW:key] → touche "' + e.key + '" non gérée');
                }
            }

            function attachJWEndDetectors(player) {
                _dom('[JW:endDetect] → initialisation des détecteurs de fin');
                var sent = false;

                function sendNext(src) {
                    if (sent) {
                        _skip('[JW:endDetect] → sendNext doublon ignoré (src:', src, ')');
                        return;
                    }
                    sent = true;
                    _log('[JW:endDetect] → nextEp envoyé via :', src);
                    window.parent.postMessage({ action: 'nextEp' }, '*');
                }

                // 1) JW complete event
                player.on('complete', function() {
                    _log('[JW:endDetect] → événement "complete" JWPlayer déclenché');
                    sendNext('jw-complete');
                });

                // 2) Fallback <video> ended
                var v = document.querySelector('video');
                if (v) {
                    _dom('[JW:endDetect] → <video> trouvé, attache événement "ended" fallback');
                    v.addEventListener('ended', function() {
                        _log('[JW:endDetect] → événement "ended" <video> déclenché');
                        sendNext('video-ended');
                    });
                } else {
                    _warn('[JW:endDetect] → aucun <video> trouvé pour fallback "ended"');
                }

                // 3) Détection fin : remaining <= 0.5 pendant 2 checks consécutifs
                var endCount = 0;
                _dom('[JW:endDetect] → stallInterval démarré (check toutes les 1s)');
                var stallInterval = setInterval(function() {
                    if (sent) {
                        _skip('[JW:endDetect] → stallInterval → nextEp déjà envoyé, clearInterval');
                        clearInterval(stallInterval);
                        return;
                    }
                    try {
                        var dur = player.getDuration();
                        var pos = player.getPosition();
                        if (!isFinite(dur) || dur <= 0) return;
                        var remaining = dur - pos;
                        if (remaining <= 0.5) {
                            endCount++;
                            _log('[JW:endDetect] → fin proche | count:', endCount, '| remaining:', remaining.toFixed(3), 's | pos:', pos.toFixed(2), '/ dur:', dur.toFixed(2));
                            if (endCount >= 2) {
                                _log('[JW:endDetect] → seuil atteint (count ≥ 2) → sendNext jw-end-detect');
                                sendNext('jw-end-detect');
                                clearInterval(stallInterval);
                            }
                        } else {
                            if (endCount > 0) {
                                _skip('[JW:endDetect] → endCount réinitialisé (remaining:', remaining.toFixed(3), ')');
                            }
                            endCount = 0;
                        }
                    } catch (err) {
                        _warn('[JW:endDetect] → stallInterval erreur :', err.message);
                    }
                }, 1000);
            }

            function attachJWIframeHandlers(player) {
                _log('[JW:iframe] → initialisation handlers iframe | state:', player.getState());

                document.addEventListener('keydown', function(e) { jwKeyHandler(player, e); }, true);
                _dom('[JW:iframe] → keydown listener attaché (capture=true)');

                window.addEventListener('message', function(e) {
                    if (!e.data || !e.data.action) {
                        _skip('[JW:iframe:msg] → message sans action ignoré', e.data);
                        return;
                    }
                    _msg('[JW:iframe:msg] → action reçue :', e.data.action, '| value:', e.data.value, '| origin:', e.origin);
                    switch (e.data.action) {
                        case 'togglePlay': {
                            _dom('[JW:iframe:msg] → togglePlay → window.focus() + forcePlayJW');
                            forcePlayJW(player);
                            break;
                        }
                        case 'focusPlayer': {
                            try {
                                // Tente focus sur le conteneur JW
                                var jwEl = document.querySelector('.jwplayer, #player, .jw-video');
                                if (jwEl) { jwEl.focus(); }
                                else {
                                    var v = document.querySelector('video');
                                    if (v) v.focus();
                                }
                                _log('[JW:iframe:msg] → focusPlayer → focus appliqué');
                            } catch(_) {}
                            break;
                        }
                        case 'seekForward': {
                            var val = e.data.value || 10;
                            var pos = player.getPosition();
                            _dom('[JW:iframe:msg] → seekForward +' + val + 's |', pos.toFixed(1), '→', (pos + val).toFixed(1));
                            player.seek(pos + val);
                            break;
                        }
                        case 'seekBackward': {
                            var val2 = e.data.value || 10;
                            var pos2 = player.getPosition();
                            _dom('[JW:iframe:msg] → seekBackward -' + val2 + 's |', pos2.toFixed(1), '→', Math.max(0, pos2 - val2).toFixed(1));
                            player.seek(Math.max(0, pos2 - val2));
                            break;
                        }
                        case 'volumeUp': {
                            var val3 = e.data.value || 10;
                            var v = player.getVolume();
                            _dom('[JW:iframe:msg] → volumeUp +' + val3 + ' |', v, '→', Math.min(100, v + val3));
                            player.setVolume(Math.min(100, v + val3));
                            break;
                        }
                        case 'volumeDown': {
                            var val4 = e.data.value || 10;
                            var v2 = player.getVolume();
                            _dom('[JW:iframe:msg] → volumeDown -' + val4 + ' |', v2, '→', Math.max(0, v2 - val4));
                            player.setVolume(Math.max(0, v2 - val4));
                            break;
                        }
                        case 'toggleFullscreen': {
                            var fs = player.getFullscreen();
                            _dom('[JW:iframe:msg] → toggleFullscreen |', fs ? 'fullscreen → normal' : 'normal → fullscreen');
                            player.setFullscreen(!fs);
                            break;
                        }
                        default:
                            _skip('[JW:iframe:msg] → action non gérée :', e.data.action);
                    }
                });
                _dom('[JW:iframe] → message listener attaché ✓');

                attachJWEndDetectors(player);

                _msg('[JW:iframe] → postMessage Istart → parent');
                window.parent.postMessage({ action: 'Istart' }, '*');
                _log('[JW:iframe] → initialisation complète ✓');
            }

            //**********************
            //* ROUTAGE PARENT / IFRAME
            //**********************
            function attachParentHandlers() {
                _log('[route:parent] → initialisation handlers parent');
                document.addEventListener('keydown', parentKeyHandler, true);
                _dom('[route:parent] → keydown listener attaché (capture=true) ✓');
                window.addEventListener('message', messageHandler);
                _dom('[route:parent] → message listener attaché ✓');
                _log('[route:parent] → initialisation complète ✓');
            }

            function attachIframeHandlers() {
                _log('[route:iframe] → initialisation handlers iframe');

                // Détection synchrone JW
                var jwp = detectJWPlayer();
                if (jwp) {
                    _log('[route:iframe] → JWPlayer détecté synchronement → attachJWIframeHandlers');
                    attachJWIframeHandlers(jwp);
                    return;
                }
                _skip('[route:iframe] → pas de JWPlayer synchrone → fallback vidéo natif');

                // Envoyer Istart + attacher video natif TOUT DE SUITE
                document.addEventListener('keydown', iframeKeyHandler, true);
                _dom('[route:iframe] → keydown listener (natif) attaché (capture=true) ✓');

                window.addEventListener('message', function(e) {
                    if (!e.data || !e.data.action) {
                        _skip('[route:iframe:msg] → message sans action ignoré', e.data);
                        return;
                    }
                    _msg('[route:iframe:msg] → action reçue :', e.data.action, '| value:', e.data.value, '| origin:', e.origin);

                    var v;
                    switch (e.data.action) {
                        case 'togglePlay': {
                            _dom('[route:iframe:msg] → togglePlay → focus + togglePlayPauseAfterDelay');
                            try { window.focus(); } catch(err) { _warn('[route:iframe:msg] → window.focus() échoué :', err); }
                            v = document.querySelector('video');
                            if (v) {
                                v.focus();
                                _dom('[route:iframe:msg] → <video> focus ✓');
                            } else {
                                _warn('[route:iframe:msg] → togglePlay : aucun <video> trouvé pour focus');
                            }
                            togglePlayPauseAfterDelay();
                            break;
                        }
                        case 'seekForward': {
                            v = document.querySelector('video');
                            if (v) {
                                var sfVal = e.data.value || 10;
                                var sfNew = Math.min(v.duration, v.currentTime + sfVal);
                                _dom('[route:iframe:msg] → seekForward +' + sfVal + 's |', v.currentTime.toFixed(1), '→', sfNew.toFixed(1));
                                v.currentTime = sfNew;
                            } else {
                                _warn('[route:iframe:msg] → seekForward : aucun <video>');
                            }
                            break;
                        }

                        case 'seekBackward': {
                            v = document.querySelector('video');
                            if (v) {
                                var sbVal = e.data.value || 10;
                                var sbNew = Math.max(0, v.currentTime - sbVal);
                                _dom('[route:iframe:msg] → seekBackward -' + sbVal + 's |', v.currentTime.toFixed(1), '→', sbNew.toFixed(1));
                                v.currentTime = sbNew;
                            } else {
                                _warn('[route:iframe:msg] → seekBackward : aucun <video>');
                            }
                            break;
                        }

                        case 'volumeUp': {
                            v = document.querySelector('video');
                            if (v) {
                                var vuVal = (e.data.value || 10) / 100;
                                var vuNew = Math.min(1, v.volume + vuVal);
                                _dom('[route:iframe:msg] → volumeUp +' + (vuVal * 100) + '% |', v.volume.toFixed(2), '→', vuNew.toFixed(2));
                                v.volume = vuNew;
                            } else {
                                _warn('[route:iframe:msg] → volumeUp : aucun <video>');
                            }
                            break;
                        }

                        case 'volumeDown': {
                            v = document.querySelector('video');
                            if (v) {
                                var vdVal = (e.data.value || 10) / 100;
                                var vdNew = Math.max(0, v.volume - vdVal);
                                _dom('[route:iframe:msg] → volumeDown -' + (vdVal * 100) + '% |', v.volume.toFixed(2), '→', vdNew.toFixed(2));
                                v.volume = vdNew;
                            } else {
                                _warn('[route:iframe:msg] → volumeDown : aucun <video>');
                            }
                            break;
                        }

                        case 'toggleFullscreen':
                            v = document.querySelector('video');
                            if (v) {
                                if (document.fullscreenElement) {
                                    _dom('[route:iframe:msg] → toggleFullscreen → exitFullscreen');
                                    document.exitFullscreen();
                                } else if (v.requestFullscreen) {
                                    _dom('[route:iframe:msg] → toggleFullscreen → requestFullscreen');
                                    v.requestFullscreen();
                                } else {
                                    _warn('[route:iframe:msg] → toggleFullscreen : requestFullscreen non supporté');
                                }
                            } else {
                                _warn('[route:iframe:msg] → toggleFullscreen : aucun <video>');
                            }
                            break;

                        default:
                            _skip('[route:iframe:msg] → action non gérée :', e.data.action);
                    }
                });
                _dom('[route:iframe] → message listener (natif) attaché ✓');

                addVideoEndDetectors();

                // Envoyer Istart dès que possible
                function sendIstart() {
                    _msg('[route:iframe] → postMessage Istart → parent');
                    window.parent.postMessage({ action: 'Istart' }, '*');
                }
                if (document.readyState === 'complete') {
                    _dom('[route:iframe] → readyState=complete → sendIstart dans 100ms');
                    setTimeout(sendIstart, 100);
                } else {
                    _dom('[route:iframe] → readyState=' + document.readyState + ' → sendIstart sur événement "load"');
                    window.addEventListener('load', function() {
                        _dom('[route:iframe] → événement "load" reçu → sendIstart dans 100ms');
                        setTimeout(sendIstart, 100);
                    });
                }

                // Tenter JW en arrière-plan (upgrade si trouvé)
                _dom('[route:iframe] → waitForJWPlayer (3s) lancé en arrière-plan');
                waitForJWPlayer(3000).then(function(player) {
                    _log('[route:iframe] → JWPlayer détecté tardivement → upgrade → removeEventListener iframeKeyHandler');
                    document.removeEventListener('keydown', iframeKeyHandler, true);
                    attachJWIframeHandlers(player);
                }).catch(function() {
                    _skip('[route:iframe] → waitForJWPlayer timeout → confirmé : pas de JW, vidéo natif actif');
                });
            }

            // ── Routage principal ──────────────────────────────────────────────────────
            _log('[route] → démarrage routage | fromAnimeParent:', fromAnimeParent, '| fromAnimeIframe:', fromAnimeIframe, '| isTop:', isTop);

            if (fromAnimeParent) {
                _log('[route] → contexte PARENT détecté → attachParentHandlers');
                attachParentHandlers();
            } else if (fromAnimeIframe) {
                _log('[route] → contexte IFRAME détecté → attachIframeHandlers');
                attachIframeHandlers();
            } else {
                var hasPlayerIframeId = !!document.getElementById('playerDF');
                var hasVideo = !!document.querySelector('video');
                _warn('[route] → contexte INCONNU | fallback | hasPlayerIframeId:', hasPlayerIframeId, '| hasVideo:', hasVideo, '| isTop:', isTop, '| host:', SITE);

                if (isTop && hasPlayerIframeId) {
                    _warn('[route:fallback] → isTop + #playerDF trouvé → attachParentHandlers');
                    attachParentHandlers();
                } else if (!isTop && hasVideo) {
                    _warn('[route:fallback] → iframe + <video> trouvé → attachIframeHandlers');
                    attachIframeHandlers();
                } else if (!isTop) {
                    _warn('[route:fallback] → iframe sans <video> détecté → attachIframeHandlers (spéculatif)');
                    attachIframeHandlers();
                } else {
                    _err('[route:fallback] → aucune condition remplie → rien attaché | host:', SITE, '| href:', location.href);
                }
            }

        })();
    `;

    Utils.log('[inject] → création du script injecté');
    const script = document.createElement('script');
    script.defer = true;
    script.textContent = injectedCode;
    Utils.log('[inject] → injection dans documentElement');
    document.documentElement.appendChild(script);
    script.remove();
    Utils.log('[inject] → script injecté et retiré du DOM');

})();