MDK Orange TV

Ajuste la grille de Orange TV, gère le volume au clavier/souris, cache les chaînes non souscrites et offre des raccourcis.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MDK Orange TV
// @namespace    MDK Scripts
// @version      2026.05.26.7
// @description  Ajuste la grille de Orange TV, gère le volume au clavier/souris, cache les chaînes non souscrites et offre des raccourcis.
// @author       MDK
// @license      MIT
// @match        https://tv.orange.fr/*
// @run-at       document-start
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_addValueChangeListener
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tv.orange.fr
// ==/UserScript==

(function() {
    'use strict';

    // Configuration des constantes globales et des limites de valeurs
    const ICON_ORANGETV = "https://www.google.com/s2/favicons?sz=64&domain=tv.orange.fr";
    const PLAY_URL_REGEX = /^https?:\/\/.*tv\.orange\.fr\/lecture\/en-direct\/chaines\/livetv.*/;

    const DEFAULTS = { nbreCol: 4, gapX: 18, osdSize: 48, hideChannels: true };
    const LIMITS = {
        nbreCol: { min: 2, max: 8 },
        gapX: { min: 6, max: 64 },
        osdSize: { min: 16, max: 72 }
    };

    const clamp = (val, min, max) => Math.max(min, Math.min(max, val));

    // Chargement de la configuration utilisateur avec repli sur les valeurs par défaut
    const config = {
        nbreCol: clamp(parseInt(GM_getValue('nbreCol', DEFAULTS.nbreCol), 10), LIMITS.nbreCol.min, LIMITS.nbreCol.max),
        gapX: clamp(parseInt(GM_getValue('gapX', DEFAULTS.gapX), 10), LIMITS.gapX.min, LIMITS.gapX.max),
        osdSize: clamp(parseInt(GM_getValue('osdSize', DEFAULTS.osdSize), 10), LIMITS.osdSize.min, LIMITS.osdSize.max),
        hideChannels: GM_getValue('hideChannels', DEFAULTS.hideChannels)
    };

    const syncChannel = new BroadcastChannel('mdk_orange_tv_sync');

    // Injection des styles CSS de base pour l'interface et le masquage des chaînes
    GM_addStyle(`
        :root {
            --popup-bg: #ffffff;
            --popup-text: #333333;
            --popup-border: #cccccc;
            --popup-input-bg: #ffffff;
            --halo-color: rgba(241, 110, 0, 0.5);
            --focus-outline: #f16e00;
            --mdk-orange-cols: ${config.nbreCol};
            --mdk-orange-gap: ${config.gapX}px;
            --osd-font-size: ${config.osdSize}px;
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --popup-bg: #1f1f1f;
                --popup-text: #f0f0f0;
                --popup-border: #444444;
                --popup-input-bg: #2d2d2d;
                --halo-color: rgba(241, 110, 0, 0.7);
            }
        }

        :root.mdk-hide-channels #listing-mosaic li:has(div.corner),
        :root.mdk-hide-channels .section-container .stvui-strip:has(img[src*="buy-white.png"]),
        :root.mdk-hide-channels .stvui-vertical-list li:has(div.corner),
        :root.mdk-hide-channels .stvui-mosaic li:has(div.corner) {
            display: none !important;
        }

        .stvui-mosaic.medium.landscape {
            --nb-media: var(--mdk-orange-cols) !important;
            --media-horizontal-gap: var(--mdk-orange-gap) !important;
        }

        .gm-overlay {
            position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
            background-color: rgba(0,0,0,0.6); display: flex;
            justify-content: center; align-items: center; z-index: 2147483647; font-family: sans-serif;
            animation: fadeIn 0.2s ease-out;
        }
        @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

        .gm-popup {
            background-color: var(--popup-bg); color: var(--popup-text);
            padding: 22px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.4);
            width: 420px; display: flex; flex-direction: column; gap: 16px;
            border: 1px solid var(--popup-border); box-sizing: border-box;
        }
        .gm-popup-header { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
        .gm-popup-icon { width: 28px; height: 28px; object-fit: contain; }
        .gm-popup-title { margin: 0; font-size: 18px; font-weight: bold; }
        .gm-popup-row { display: flex; justify-content: space-between; align-items: center; width: 100%; }

        .gm-popup-help {
            font-size: 11px; color: #888888; border-top: 1px dashed var(--popup-border);
            padding-top: 10px; margin-top: 4px; line-height: 1.5;
        }

        .gm-popup-footer { display: flex; justify-content: space-between; margin-top: 4px; gap: 10px; width: 100%; }

        .gm-popup-input {
            width: 75px; padding: 6px 10px; background: var(--popup-input-bg);
            color: var(--popup-text); border: 1px solid var(--popup-border);
            border-radius: 4px; text-align: center; box-sizing: border-box;
            font-size: 14px;
        }
        .gm-popup-input:focus, .gm-btn-action:focus, #input-hideChannels:focus { outline: 2px solid var(--focus-outline) !important; }

        .gm-btn-action {
            flex: 1; padding: 10px 14px; color: #fff; border: none;
            border-radius: 4px; cursor: pointer; font-weight: bold;
            transition: background 0.15s ease; text-align: center;
            font-size: 14px; white-space: nowrap;
        }
        #btn-save { background: #f16e00; animation: gm-pulse-halo 2s infinite; }
        #btn-save:focus { animation: none; }
        #btn-cancel { background: #6c757d; }
        #btn-reset { background: #dc3545; }

        #btn-save:hover { background: #d66100; }
        #btn-cancel:hover { background: #5a6268; }
        #btn-reset:hover { background: #bd2130; }

        @keyframes gm-pulse-halo {
            0% { box-shadow: 0 0 0 0 var(--halo-color); }
            70% { box-shadow: 0 0 0 8px rgba(241, 110, 0, 0); }
            100% { box-shadow: 0 0 0 0 rgba(241, 110, 0, 0); }
        }

        .gm-volume-indicator {
            position: fixed; top: 30px; right: 30px; background: rgba(0, 0, 0, 0.85);
            color: #fff; padding: 12px 24px; border-radius: 16px; font-family: sans-serif;
            font-size: var(--osd-font-size); font-weight: bold; z-index: 2147483647;
            pointer-events: none; transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
            opacity: 0; transform: translateY(-20px);
            display: flex; flex-direction: column; align-items: center; gap: 6px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
        }
        .gm-volume-indicator.show { opacity: 1; transform: translateY(0); }
        .gm-vol-bar-container { width: 100%; height: 6px; background: rgba(255,255,255,0.2); border-radius: 3px; overflow: hidden; margin-top: 4px;}
        .gm-vol-bar { height: 100%; background: #f16e00; width: 0%; transition: width 0.15s ease-out, background 0.3s ease; }
    `);

    // Application dynamique des variables CSS sur l'élément racine du document
    function applyLiveStyle(cols, gap, osdSize, hideChannels) {
        const root = document.documentElement;
        if (!root) return;
        root.style.setProperty('--mdk-orange-cols', cols);
        root.style.setProperty('--mdk-orange-gap', `${gap}px`);
        root.style.setProperty('--osd-font-size', `${osdSize}px`);

        if (hideChannels) {
            root.classList.add('mdk-hide-channels');
        } else {
            root.classList.remove('mdk-hide-channels');
        }
    }

    // Synchronisation globale de l'affichage avec l'état de configuration actuel
    function updateCSSVariables() {
        applyLiveStyle(config.nbreCol, config.gapX, config.osdSize, config.hideChannels);
    }

    // Détermination du conteneur parent approprié pour l'affichage des éléments graphiques
    function getContainer() {
        return document.fullscreenElement || document.body || document.documentElement;
    }

    let volumeTimeout = null;

    // Gestion de l'affichage graphique de la barre d'OSD pour le volume
    function showVolumeOSD(video) {
        const container = getContainer();
        if (!container) return;

        let indicator = document.getElementById('gm-vol-osd');

        if (!indicator) {
            indicator = document.createElement('div');
            indicator.id = 'gm-vol-osd';
            indicator.className = 'gm-volume-indicator';
            indicator.innerHTML = `<span id="gm-vol-text"></span><div class="gm-vol-bar-container"><div id="gm-vol-bar" class="gm-vol-bar"></div></div>`;
            container.appendChild(indicator);
        } else if (indicator.parentNode !== container) {
            container.appendChild(indicator);
        }

        const textEl = document.getElementById('gm-vol-text');
        const barEl = document.getElementById('gm-vol-bar');

        if (!textEl || !barEl) return;

        const volPercent = video.muted ? 0 : Math.round(video.volume * 100);
        const icon = video.muted || volPercent === 0 ? '🔇' : volPercent < 40 ? '🔈' : volPercent < 80 ? '🔉' : '🔊';

        textEl.textContent = `${icon} ${volPercent}%`;
        barEl.style.width = `${volPercent}%`;
        barEl.style.background = video.muted ? '#6c757d' : '#f16e00';

        indicator.classList.add('show');

        clearTimeout(volumeTimeout);
        volumeTimeout = setTimeout(() => { indicator.classList.remove('show'); }, 2000);
    }

    // Ajustement du niveau de volume de l'élément vidéo actif
    function adjustVolume(step) {
        if (!PLAY_URL_REGEX.test(window.location.href)) return;
        const video = document.querySelector('video');
        if (!video) return;

        video.muted = false;
        let currentVol = Math.round(video.volume * 100);
        let newVol = clamp(currentVol + step, 0, 100);

        video.volume = newVol / 100;
        showVolumeOSD(video);
    }

    // Interception des actions de la molette de la souris pour contrôler le volume
    function handleVolumeWheel(e) {
        if (!PLAY_URL_REGEX.test(window.location.href)) return;
        if (e.target.closest('#gm-settings-popup, .stvui-vertical-list')) return;

        const style = window.getComputedStyle(e.target);
        if (e.target.scrollHeight > e.target.clientHeight && (style.overflowY === 'auto' || style.overflowY === 'scroll')) {
            return;
        }

        e.preventDefault();
        const step = e.deltaY < 0 ? 5 : -5;
        adjustVolume(step);
    }

    window.addEventListener('wheel', handleVolumeWheel, { passive: false });

    // Génération et orchestration des événements de la popup de configuration
    function createSettingsPopup() {
        if (document.getElementById('gm-settings-popup')) return;

        const container = getContainer();
        if (!container) return;

        const initialCols = config.nbreCol;
        const initialGap = config.gapX;
        const initialOsdSize = config.osdSize;
        const initialHideChannels = config.hideChannels;

        const overlay = document.createElement('div');
        overlay.id = 'gm-settings-popup';
        overlay.className = 'gm-overlay';

        const popup = document.createElement('div');
        popup.className = 'gm-popup';
        popup.setAttribute('role', 'dialog');
        popup.setAttribute('aria-modal', 'true');

        popup.innerHTML = `
            <div class="gm-popup-header">
                <img src="${ICON_ORANGETV}" class="gm-popup-icon" alt="Orange TV">
                <h3 class="gm-popup-title">Configuration Orange TV</h3>
            </div>
            <div class="gm-popup-row">
                <label for="input-nbreCol">Nombre de colonnes (${LIMITS.nbreCol.min}-${LIMITS.nbreCol.max}) :</label>
                <input type="number" id="input-nbreCol" class="gm-popup-input" value="${config.nbreCol}" min="${LIMITS.nbreCol.min}" max="${LIMITS.nbreCol.max}">
            </div>
            <div class="gm-popup-row">
                <label for="input-gapX">Espacement horizontal (${LIMITS.gapX.min}-${LIMITS.gapX.max}px) :</label>
                <input type="number" id="input-gapX" class="gm-popup-input" value="${config.gapX}" min="${LIMITS.gapX.min}" max="${LIMITS.gapX.max}">
            </div>
            <div class="gm-popup-row">
                <label for="input-osdSize">Taille de l'OSD (${LIMITS.osdSize.min}-${LIMITS.osdSize.max}px) :</label>
                <input type="number" id="input-osdSize" class="gm-popup-input" value="${config.osdSize}" min="${LIMITS.osdSize.min}" max="${LIMITS.osdSize.max}">
            </div>
            <div class="gm-popup-row">
                <label for="input-hideChannels">Cacher les chaînes non souscrites :</label>
                <input type="checkbox" id="input-hideChannels" ${config.hideChannels ? 'checked' : ''} style="cursor: pointer; width: 18px; height: 18px;">
            </div>
            <div class="gm-popup-help">
                <strong>Raccourcis clavier :</strong><br>
                • <kbd>[O]</kbd> : Ouvrir / Fermer cette interface de configuration<br>
                • <kbd>[H]</kbd> : Afficher / Masquer les chaînes non souscrites
            </div>
            <div class="gm-popup-footer">
                <button id="btn-save" class="gm-btn-action" type="button"><u>E</u>nregistrer</button>
                <button id="btn-cancel" class="gm-btn-action" type="button"><u>A</u>nnuler</button>
                <button id="btn-reset" class="gm-btn-action" type="button"><u>R</u>éinitialiser</button>
            </div>
        `;

        overlay.appendChild(popup);
        container.appendChild(overlay);

        const inputCol = document.getElementById('input-nbreCol');
        const inputGap = document.getElementById('input-gapX');
        const inputOsd = document.getElementById('input-osdSize');
        const checkboxHide = document.getElementById('input-hideChannels');

        const inputElements = [inputCol, inputGap, inputOsd];
        const focusableElements = Array.from(popup.querySelectorAll('input, button'));
        focusableElements[0].focus();

        // Traitement des changements de saisie et prévisualisation instantanée
        const triggerLiveUpdate = () => {
            const c = clamp(parseInt(inputCol.value, 10) || DEFAULTS.nbreCol, LIMITS.nbreCol.min, LIMITS.nbreCol.max);
            const g = clamp(parseInt(inputGap.value, 10) || DEFAULTS.gapX, LIMITS.gapX.min, LIMITS.gapX.max);
            const os = clamp(parseInt(inputOsd.value, 10) || DEFAULTS.osdSize, LIMITS.osdSize.min, LIMITS.osdSize.max);
            const hc = checkboxHide.checked;

            applyLiveStyle(c, g, os, hc);
            syncChannel.postMessage({ type: 'preview', nbreCol: c, gapX: g, osdSize: os, hideChannels: hc });
        };

        inputElements.forEach(input => input.addEventListener('input', triggerLiveUpdate));
        checkboxHide.addEventListener('change', triggerLiveUpdate);

        // Gestion de l'incrémentation numérique via la molette de la souris sur les inputs
        const wheelInputHandler = (e) => {
            e.stopPropagation();
            e.preventDefault();
            const input = e.target;
            let val = parseInt(input.value, 10) || 0;
            val = e.deltaY < 0 ? val + 1 : val - 1;
            input.value = clamp(val, parseInt(input.min, 10), parseInt(input.max, 10));
            triggerLiveUpdate();
        };

        inputElements.forEach(input => input.addEventListener('wheel', wheelInputHandler, { passive: false }));

        // Nettoyage des écouteurs et suppression de l'affichage modal
        const close = () => {
            document.removeEventListener('keydown', handleKeydown);
            inputElements.forEach(input => {
                input.removeEventListener('wheel', wheelInputHandler);
                input.removeEventListener('input', triggerLiveUpdate);
            });
            checkboxHide.removeEventListener('change', triggerLiveUpdate);
            overlay.remove();
        };

        // Restauration de l'état d'origine de l'interface lors d'une annulation
        const cancel = () => {
            applyLiveStyle(initialCols, initialGap, initialOsdSize, initialHideChannels);
            syncChannel.postMessage({ type: 'preview', nbreCol: initialCols, gapX: initialGap, osdSize: initialOsdSize, hideChannels: initialHideChannels });
            close();
        };

        // Sauvegarde définitive des paramètres mis à jour dans le stockage persistant
        const save = () => {
            config.nbreCol = clamp(parseInt(inputCol.value, 10) || DEFAULTS.nbreCol, LIMITS.nbreCol.min, LIMITS.nbreCol.max);
            config.gapX = clamp(parseInt(inputGap.value, 10) || DEFAULTS.gapX, LIMITS.gapX.min, LIMITS.gapX.max);
            config.osdSize = clamp(parseInt(inputOsd.value, 10) || DEFAULTS.osdSize, LIMITS.osdSize.min, LIMITS.osdSize.max);
            config.hideChannels = checkboxHide.checked;

            GM_setValue('nbreCol', config.nbreCol);
            GM_setValue('gapX', config.gapX);
            GM_setValue('osdSize', config.osdSize);
            GM_setValue('hideChannels', config.hideChannels);

            updateCSSVariables();

            const saveBtn = document.getElementById('btn-save');
            saveBtn.textContent = "Enregistré ! ✅";
            setTimeout(close, 400);
        };

        // Rétablissement de l'ensemble des configurations par défaut de l'application
        const reset = () => {
            inputCol.value = DEFAULTS.nbreCol;
            inputGap.value = DEFAULTS.gapX;
            inputOsd.value = DEFAULTS.osdSize;
            checkboxHide.checked = DEFAULTS.hideChannels;
            triggerLiveUpdate();
        };

        document.getElementById('btn-save').addEventListener('click', save);
        document.getElementById('btn-cancel').addEventListener('click', cancel);
        document.getElementById('btn-reset').addEventListener('click', reset);

        // Préservation de l'édition textuelle native et isolation de la navigation par flèches aux éléments non textuels
        function handleKeydown(e) {
            const activeElement = document.activeElement;
            const currentIndex = focusableElements.indexOf(activeElement);
            const isInput = activeElement.classList.contains('gm-popup-input');

            if (e.altKey) {
                const altKey = e.key.toLowerCase();
                if (altKey === 'e') {
                    e.preventDefault();
                    save();
                    return;
                }
                if (altKey === 'a') {
                    e.preventDefault();
                    cancel();
                    return;
                }
                if (altKey === 'r') {
                    e.preventDefault();
                    reset();
                    return;
                }
            }

            if (e.key === 'Escape') {
                e.preventDefault();
                cancel();
                return;
            }

            if (e.key === 'Enter') {
                if (activeElement.tagName === 'BUTTON' && activeElement.id !== 'btn-save') return;
                e.preventDefault();
                save();
                return;
            }

            if (e.key === 'Tab') {
                e.preventDefault();
                let nextIndex = e.shiftKey ? currentIndex - 1 : currentIndex + 1;
                if (nextIndex < 0) nextIndex = focusableElements.length - 1;
                if (nextIndex >= focusableElements.length) nextIndex = 0;
                focusableElements[nextIndex].focus();
                return;
            }

            if (isInput) {
                if (e.key.toLowerCase() === 'o' || e.key.toLowerCase() === 'h') return;

                if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
                    e.preventDefault();
                    let val = parseInt(activeElement.value, 10) || 0;
                    val = (e.key === 'ArrowUp') ? val + 1 : val - 1;
                    activeElement.value = clamp(val, parseInt(activeElement.min, 10), parseInt(activeElement.max, 10));
                    triggerLiveUpdate();
                    return;
                }

                if (e.ctrlKey || e.metaKey || ['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Backspace', 'Delete'].includes(e.key)) {
                    return;
                }
            } else {
                if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(e.key)) {
                    e.preventDefault();
                    let move = (e.key === 'ArrowDown' || e.key === 'ArrowRight') ? 1 : -1;
                    let nextIndex = currentIndex + move;
                    if (nextIndex < 0) nextIndex = focusableElements.length - 1;
                    if (nextIndex >= focusableElements.length) nextIndex = 0;
                    focusableElements[nextIndex].focus();
                }
            }
        }

        document.addEventListener('keydown', handleKeydown);
    }

    GM_registerMenuCommand("⚙️ Options", createSettingsPopup);

    // Interception et traitement des commandes et raccourcis clavier globaux
    window.addEventListener('keydown', (e) => {
        if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable ||
            (e.target.tagName === 'INPUT' && !e.target.classList.contains('gm-popup-input') && e.target.id !== 'input-hideChannels')) {
            return;
        }

        const key = e.key.toLowerCase();
        if (key === 'o') {
            e.preventDefault();
            const existingPopup = document.getElementById('gm-settings-popup');
            if (existingPopup) {
                existingPopup.querySelector('#btn-cancel').click();
            } else {
                createSettingsPopup();
            }
            return;
        }

        if (key === 'h') {
            e.preventDefault();
            config.hideChannels = !config.hideChannels;
            GM_setValue('hideChannels', config.hideChannels);
            updateCSSVariables();

            const chkHide = document.getElementById('input-hideChannels');
            if (chkHide) chkHide.checked = config.hideChannels;
            return;
        }

        if (PLAY_URL_REGEX.test(window.location.href) && !document.getElementById('gm-settings-popup')) {
            if (e.key === 'ArrowUp') {
                e.preventDefault();
                adjustVolume(5);
            }
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                adjustVolume(-5);
            }
        }
    }, true);

    // Écoute de la communication entre onglets pour actualiser l'interface en direct
    syncChannel.onmessage = (event) => {
        if (event.data.type === 'preview') {
            const { nbreCol, gapX, osdSize, hideChannels } = event.data;
            applyLiveStyle(nbreCol, gapX, osdSize, hideChannels);

            ['nbreCol', 'gapX', 'osdSize'].forEach(key => {
                const input = document.getElementById(`input-${key}`);
                if (input) input.value = event.data[key];
            });
            const chkHide = document.getElementById('input-hideChannels');
            if (chkHide) chkHide.checked = hideChannels;
        }
    };

    // Synchronisation automatique suite à un changement de données provenant d'un autre onglet
    ['nbreCol', 'gapX', 'osdSize', 'hideChannels'].forEach(key => {
        GM_addValueChangeListener(key, (name, oldVal, newVal, remote) => {
            if (remote) {
                config[name] = newVal;
                updateCSSVariables();

                if (name === 'hideChannels') {
                    const chkHide = document.getElementById('input-hideChannels');
                    if (chkHide) chkHide.checked = newVal;
                } else {
                    const input = document.getElementById(`input-${name}`);
                    if (input) input.value = newVal;
                }
            }
        });
    });

    // Initialisation globale de l'interface en fonction de l'état de chargement du document
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', updateCSSVariables);
    } else {
        updateCSSVariables();
    }
})();