OMNI: Social Redirector, Search Bar & Smart Title Fixer

A context-aware, zero-overhead overlay that instantly redirects social media profiles to alternative frontends (Nitter, Invidious, etc.) and searches across multiple platforms. Includes a built-in settings dashboard and a Smart Tab Title fixer.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         OMNI: Social Redirector, Search Bar & Smart Title Fixer
// @namespace    https://greasyfork.org/fr/users/1504296-yayornay
// @version      7.2.3
// @description  A context-aware, zero-overhead overlay that instantly redirects social media profiles to alternative frontends (Nitter, Invidious, etc.) and searches across multiple platforms. Includes a built-in settings dashboard and a Smart Tab Title fixer.
// @author       Mediakeo
// @match        *://*.x.com/*
// @match        *://*.twitter.com/*
// @match        *://*.nitter.net/*
// @match        *://*.xcancel.com/*
// @match        *://*.nitter.space/*
// @match        *://*.youtube.com/*
// @match        *://*.yewtu.be/*
// @match        *://*.invidious.nerdvpn.de/*
// @match        *://*.piped.video/*
// @match        *://*.instagram.com/*
// @match        *://*.imginn.com/*
// @match        *://*.dumpor.io/*
// @match        *://*.threads.com/*
// @match        *://*.threads.net/*
// @match        *://*.bsky.app/*
// @match        *://*.tiktok.com/*
// @match        *://*.proxitok.pabloferreiro.xyz/*
// @match        *://*.twitch.tv/*
// @match        *://*.facebook.com/*
// @match        *://*.linktr.ee/*
// @match        *://*.beacons.ai/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

// 7.2.2 avec options de positionnement et redimensionnement

(function () {
  'use strict';

  if (window.self !== window.top) return;

  // ─────────────────────────────────────────────
  // 1. DEFAULTS & CONFIG — lus une seule fois
  // ─────────────────────────────────────────────
  const D = {
    master:     'true',
    fontSize:   '11px',
    // ── Position & Taille ────────────────────────
    scale_val:  '1.0',      // Facteur d'échelle (ex: 0.8 pour 80%)
    anchor_y:   'top',      // 'top' ou 'bottom'
    pos_y:      '55px',     // 'px' ou '%'
    anchor_x:   'right',    // 'left' ou 'right'
    pos_x:      '20px',     // 'px' ou '%'
    // ─────────────────────────────────────────────
    fixed:      'true',
    opacity:    '0.92',
    show_rx:    'true',
    show_x:     'true',
    show_yt:    'true',
    show_ig:    'true',
    show_tt:    'true',
    show_tw:    'true',
    show_lt:    'true',
    // ── Smart Title Fix ──────────────────────────
    // master : active/désactive toute la fonctionnalité
    // title_* : active/désactive site par site (via menu TM)
    // Reddit intentionnellement absent : aucune détection sur ce site
    title_fix:  'true',
    title_fb:   'true',
    title_ig:   'true',
    title_x:    'true',
    title_th:   'true',
    title_tt:   'true',
    title_tw:   'true',
    title_bsky: 'true',
    title_lt:   'true',
    // ── Instances ────────────────────────────────
    rx_inst:    'Facebook:facebook.com,Mastodon:mastodon.social,Threads:threads.net,Bluesky:bsky.app,Google:google.com',
    x_inst:     'X:x.com,Twitter:twitter.com,nitter:nitter.net,xcancel:xcancel.com,nitter.space:nitter.space',
    yt_inst:    'YouTube:youtube.com,yewtu.be:yewtu.be,invidious:invidious.nerdvpn.de,piped:piped.video',
    ig_inst:    'Instagram:instagram.com,imginn:imginn.com,dumpor:dumpor.io',
    tt_inst:    'TikTok:tiktok.com,proxitok:proxitok.pabloferreiro.xyz,tokfurious:tokfurious.ch',
    tw_inst:    'Twitch:twitch.tv,TwTracker:twitchtracker.com,SullyGnome:sullygnome.com/channel,StrCharts:streamscharts.com/channels',
    lt_inst:    'Linktree:linktr.ee,Beacons:beacons.ai',
    socials:    'BSky:https://bsky.app/search?q={{USER}},Insta:https://www.instagram.com/{{USER}}/,THD:https://www.threads.net/@{{USER}},TTK:https://www.tiktok.com/@{{USER}},MAS:https://mastodon.social/search?q=%40{{USER}},GOO:https://www.google.com/search?q=%22{{USER}}%22,YT:https://www.youtube.com/results?search_query=%22{{USER}}%22,RDT:https://www.reddit.com/search/?q={{USER}},TW:https://www.twitch.tv/{{USER}},X:https://x.com/search?q={{USER}},FB:https://www.facebook.com/search/top?q={{USER}},LKT:https://linktr.ee/{{USER}}',
    custom_1:   'Reddit|#ff4500|https://{{DOM}}/user/{{USER}} >>> Libreddit:libreddit.spike.codes',
    custom_2:   '',
  };

  const CFG = {};
  for (const k in D) CFG[k] = GM_getValue(k, D[k]);

  const masterEnabled = CFG.master === 'true';
  const isFixed       = CFG.fixed  === 'true';

  // ─────────────────────────────────────────────
  // 2. CSS HOVER — injecté une seule fois,
  //    remplace les N×2 event listeners JS
  // ─────────────────────────────────────────────
  const style = document.createElement('style');
  style.textContent = '#omni-bar a:hover{background-color:rgba(255,255,255,0.12)!important}';
  document.head.appendChild(style);

  // ─────────────────────────────────────────────
  // 3. PARSERS — exécutés une seule fois
  //    indexOf au lieu de split(':') pour les
  //    domaines contenant un slash (sullygnome…)
  // ─────────────────────────────────────────────
  function parseInstances(raw) {
    if (!raw) return [];
    return raw.split(',').map(item => {
      const i = item.indexOf(':');
      if (i === -1) return null;
      const name   = item.slice(0, i).trim();
      const domain = item.slice(i + 1).trim();
      return (name && domain) ? { name, domain } : null;
    }).filter(Boolean);
  }

  function parseCustomRow(raw) {
    if (!raw || !raw.includes('>>>')) return null;
    const sep  = raw.indexOf('>>>');
    const cfg  = raw.slice(0, sep);
    const inst = raw.slice(sep + 3);
    const [label, color, template] = cfg.split('|').map(s => s.trim());
    const instances = parseInstances(inst);
    if (!label || !template || !instances.length) return null;
    return { label, color: color || '#ffffff', template, instances };
  }

  function parseSocials(raw) {
    return raw.split(',').map(item => {
      const i = item.indexOf(':');
      if (i === -1) return null;
      return { name: item.slice(0, i).trim(), urlPattern: item.slice(i + 1).trim() };
    }).filter(Boolean);
  }

  // Résultats mis en cache — jamais re-parsés
  const INST = {
    rx: parseInstances(CFG.rx_inst),
    x:  parseInstances(CFG.x_inst),
    yt: parseInstances(CFG.yt_inst),
    ig: parseInstances(CFG.ig_inst),
    tt: parseInstances(CFG.tt_inst),
    tw: parseInstances(CFG.tw_inst),
    lt: parseInstances(CFG.lt_inst),
  };
  const CUSTOM1 = parseCustomRow(CFG.custom_1);
  const CUSTOM2 = parseCustomRow(CFG.custom_2);
  const SOCIALS = parseSocials(CFG.socials);

  // ─────────────────────────────────────────────
  // 4. COULEURS — Map O(1), précalculée
  // ─────────────────────────────────────────────
  const COLOR_MAP = new Map([
    ['BSKY','#1d54a5'],    ['BLUESKY','#1d54a5'],
    ['INSTA','#e1306c'],   ['DUMPOR','#e1306c'],   ['IMGINN','#e1306c'],
    ['THD','#ffcc00'],     ['THREADS','#ffcc00'],
    ['TTK','#00f2fe'],     ['TIKTOK','#00f2fe'],
    ['MAS','#2ebd59'],     ['MASTODON','#2ebd59'],
    ['GOO','#4285f4'],     ['GOOGLE','#4285f4'],
    ['YT','#ff3333'],      ['YOUTUBE','#ff3333'],
    ['RDT','#ff4500'],     ['REDDIT','#ff4500'],
    ['TW','#9146ff'],      ['TWITCH','#9146ff'],
    ['X','#ffffff'],       ['TWITTER','#ffffff'],
    ['FB','#1877f2'],      ['FACEBOOK','#1877f2'],
    ['LKT','#39e09b'],     ['LINKTREE','#39e09b'],
    ['BEACONS','#ff007f'],
  ]);

  function getSocialColor(name) {
    const n = name.toUpperCase();
    if (COLOR_MAP.has(n)) return COLOR_MAP.get(n);
    for (const [k, v] of COLOR_MAP) { if (n.includes(k)) return v; }
    return '#ffffff';
  }

  // ─────────────────────────────────────────────
  // 5. BLACKLIST — Set O(1)
  // ─────────────────────────────────────────────
  const BLACKLIST = new Set([
    'home','explore','notifications','messages','i','settings','search',
    'tos','privacy','about','lists','feed','trending','channel','watch',
    'shorts','reels','p','direct','accounts','embed','directory','videos',
    'clips','downloads','jobs','profile','photo.php','permalink.php',
    'groups','pages','marketplace','events','share','people',
  ]);

  // ─────────────────────────────────────────────
  // 6. SMART TITLE FIX
  //    Écrit document.title avec le vrai nom de page
  //    extrait du DOM, au lieu du générique "Facebook".
  //
  //    Reddit : absent intentionnellement.
  //      → site='unknown' dans getContext() → barre cachée
  //      → aucune tentative de correction de titre
  //
  //    Note : les sélecteurs CSS peuvent casser lors
  //    d'une mise à jour du site. Si c'est le cas,
  //    inspecter le h1/h2 avec DevTools et corriger
  //    le sélecteur dans TITLE_SELECTORS ci-dessous.
  // ─────────────────────────────────────────────
  const TITLE_SELECTORS = {
    'facebook.com':  { sel: 'h1',                                            key: 'title_fb'   },
    'instagram.com': { sel: 'header h2, h1, h2',                             key: 'title_ig'   },
    'x.com':         { sel: '[data-testid="UserName"] span:first-child, h2',  key: 'title_x'    },
    'twitter.com':   { sel: '[data-testid="UserName"] span:first-child, h2',  key: 'title_x'    },
    'threads.net':   { sel: 'h1, header h2',                                 key: 'title_th'   },
    'threads.com':   { sel: 'h1, header h2',                                 key: 'title_th'   },
    'tiktok.com':    { sel: 'h1[data-e2e="user-title"], h1',                 key: 'title_tt'   },
    'twitch.tv':     { sel: 'h1',                                             key: 'title_tw'   },
    'bsky.app':      { sel: 'h1',                                             key: 'title_bsky' },
    'linktr.ee':     { sel: 'h1',                                             key: 'title_lt'   },
    'beacons.ai':    { sel: 'h1',                                             key: 'title_lt'   },
  };

  // Noms génériques à ne pas écrire dans le titre
  const TITLE_GENERIC = new Set([
    'Facebook','Instagram','X','Twitter','Threads','TikTok','Twitch',
    'Bluesky','Linktree','Beacons','Home','Explore','Search','Feed',
  ]);

function fixTitle() {
    if (CFG.title_fix !== 'true') return;
    const host  = window.location.hostname;
    const entry = Object.entries(TITLE_SELECTORS).find(([domain]) => host.includes(domain));
    if (!entry) return;

    const [domain, { sel, key }] = entry;
    if (CFG[key] !== 'true') return;

    let name = null;

    // 1. Scanner TOUS les éléments (indispensable pour FB qui cache le 1er h1)
    const elements = document.querySelectorAll(sel);
    for (const el of elements) {
      // innerText ignore les balises invisibles, textContent est le fallback
      const txt = el.innerText?.trim() || el.textContent?.trim();

      // Si on a du texte valide et qu'il n'est pas dans la blacklist générique
      if (txt && txt.length >= 2 && !TITLE_GENERIC.has(txt)) {
        name = txt;
        break; // On a trouvé le vrai titre, on sort de la boucle
      }
    }

    // 2. Fallback sur les Meta Tags (si le DOM est lent mais le header est chargé)
    if (!name) {
      const meta = document.querySelector('meta[property="og:title"]');
      if (meta && meta.content && !TITLE_GENERIC.has(meta.content)) {
        name = meta.content;
      }
    }

    // 3. Nettoyage spécifique selon la plateforme
    if (name) {
      if (domain === 'facebook.com') {
        // Enlève " | Facebook", " - Facebook", et le préfixe "Groupe "
        name = name.split(' | ')[0].split(' - ')[0].replace(/^Groupe\s+/i, '').trim();
      } else {
        // Nettoyage générique pour les autres (ex: supprime "@username" à la fin)
        name = name.replace(/(@|\/).*/, '').trim();
      }
    }

    // 4. Injection dans l'onglet
    if (name && name.length >= 2 && !TITLE_GENERIC.has(name)) {
      if (document.title !== name) {
        document.title = name;
      }
    }
  }

  // ─────────────────────────────────────────────
  // 7. EXTRACTION DU CONTEXTE
  //    Appelée uniquement sur changement d'URL.
  //    Lit document.title APRÈS fixTitle() pour
  //    bénéficier du titre corrigé dans searchName.
  // ─────────────────────────────────────────────
  function getContext() {
    const host     = window.location.hostname;
    const path     = window.location.pathname;
    const href     = window.location.href;
    const segments = path.split('/').filter(Boolean);
    const title    = document.title || '';

    let site = 'unknown', handle = null, searchName = null;
    let fbProfileId = null, ytChannelPath = null;

    if (host.includes('facebook.com') || INST.rx.some(i => host.includes(i.domain))) {
      site = 'facebook';
      const m = href.match(/id=(\d+)/);
      if (path.includes('profile.php') && m) fbProfileId = 'profile.php?id=' + m[1];
      else handle = segments[0];
    } else if (host.includes('youtube.com')) {
      if (!path.startsWith('/watch') && !path.startsWith('/shorts') && !path.startsWith('/live')) {
        site = 'youtube';
        if (['a','c','user'].includes(segments[0])) handle = segments[1];
        else if (segments[0]?.startsWith('@'))       handle = segments[0];
        else if (segments[0] === 'channel' && segments[1]) ytChannelPath = 'channel/' + segments[1];
        else handle = segments[0];
      }
    } else if (host.includes('x.com') || host.includes('twitter.com') || INST.x.some(i => host.includes(i.domain))) {
      site = 'x'; handle = segments[0];
    } else if (host.includes('instagram.com') || INST.ig.some(i => host.includes(i.domain))) {
      site = 'instagram';
      handle = host.includes('dumpor.io')
        ? (segments[0] === 'user' ? segments[1] : segments[0])
        : (['user','org'].includes(segments[0]) ? segments[1] : segments[0]);
    } else if (host.includes('threads.com') || host.includes('threads.net')) {
      site = 'threads'; handle = segments[0];
    } else if (host.includes('bsky.app')) {
      site = 'bluesky'; if (segments[0] === 'profile') handle = segments[1];
    } else if (host.includes('tiktok.com') || INST.tt.some(i => host.includes(i.domain))) {
      site = 'tiktok'; handle = segments[0];
    } else if (host.includes('twitch.tv')) {
      site = 'twitch'; handle = segments[0];
    } else if (host.includes('linktr.ee') || host.includes('beacons.ai') || INST.lt.some(i => host.includes(i.domain))) {
      site = 'linktree'; handle = segments[0];
    } else if (segments.some(s => s.startsWith('@'))) {
      site = 'mastodon';
      handle = segments.find(s => s.startsWith('@')).slice(1);
    }

    // Nettoyage handle
    if (handle) {
      handle = handle.replace(/^@/, '').split('?')[0];
      if (BLACKLIST.has(handle.toLowerCase()) || handle.length < 2) handle = null;
    }

    // searchName depuis le titre (potentiellement déjà corrigé par fixTitle)
    const GENERIC = new Set(['X','Twitter','Instagram','TikTok','Facebook','Twitch','YouTube','Threads','Bluesky']);
    const raw = title.split(' | ')[0].split(' - ')[0].split(' (')[0].replace(/(@|\/).*/, '').trim();
    searchName = (!raw || GENERIC.has(raw)) ? null : raw;

    if (!handle && searchName) handle = searchName;
    if (!searchName && handle) searchName = handle;

    return { site, handle, searchName, isFallback: !handle || handle === searchName, fbProfileId, ytChannelPath };
  }

  // ─────────────────────────────────────────────
  // 8. CRÉATION DE LIENS
  //    rel="noopener noreferrer" sur tous les liens
  // ─────────────────────────────────────────────
  function createLink(href, text, color) {
    const a = document.createElement('a');
    a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = text;
    a.style.cssText = `color:${color};text-decoration:none;padding:1px 3px;border-radius:3px;transition:background-color 0.15s`;
    return a;
  }

  function createRow(instances, ctx, baseTemplate, labelText, labelColor, useIndividualColors) {
    const { handle, isFallback, fbProfileId, ytChannelPath } = ctx;
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:2px';

    const lbl = document.createElement('span');
    lbl.textContent = labelText;
    lbl.style.cssText = `color:${labelColor};margin-right:1px`;
    row.appendChild(lbl);

    instances.forEach((inst, idx) => {
      const dom = inst.domain.toLowerCase();
      const enc = handle ? encodeURIComponent(handle) : '';
      let url = `https://${inst.domain}`;

      if (handle || (dom === 'facebook.com' && fbProfileId) || ytChannelPath) {
        if (isFallback || !handle) {
          if (dom === 'facebook.com')        url = fbProfileId ? `https://www.facebook.com/${fbProfileId}` : `https://www.facebook.com/search/top?q=${enc}`;
          else if (dom.includes('youtube') || dom.includes('yewtu') || dom.includes('invidious') || dom.includes('piped')) url = ytChannelPath ? `https://${inst.domain}/${ytChannelPath}` : `https://${inst.domain}/results?search_query=${enc}`;
          else if (dom.includes('x.com') || dom.includes('twitter') || dom.includes('nitter') || dom.includes('xcancel')) url = `https://${inst.domain}/search?q=${enc}`;
          else if (dom === 'instagram.com') url = `https://www.instagram.com/search/?q=${enc}`;
          else if (dom.includes('imginn'))  url = `https://imginn.com/search/?q=${enc}`;
          else if (dom.includes('dumpor'))  url = `https://dumpor.io/search?query=${enc}`;
          else if (dom.includes('threads')) url = `https://www.threads.net/search?q=${enc}`;
          else if (dom === 'bsky.app')      url = `https://bsky.app/search?q=${enc}`;
          else if (dom.includes('tiktok') || dom.includes('proxitok') || dom.includes('tokfurious')) url = `https://${inst.domain}/search?q=${enc}`;
          else if (dom.includes('twitch'))  url = `https://${inst.domain}/search?term=${enc}`;
          else if (dom === 'mastodon.social') url = `https://mastodon.social/search?q=${enc}`;
          else if (dom === 'google.com')    url = `https://www.google.com/search?q=%22${enc}%22`;
          else if (dom === 'linktr.ee' || dom === 'beacons.ai') url = `https://www.google.com/search?q=site:${inst.domain}+${enc}`;
          else url = baseTemplate.replace('{{DOM}}', inst.domain).replace('{{USER}}', enc);
        } else {
          if (dom.includes('imginn'))       url = `https://imginn.com/${handle}/`;
          else if (dom.includes('dumpor'))  url = `https://dumpor.io/user/${handle}`;
          else if (dom === 'instagram.com') url = `https://instagram.com/${handle}/`;
          else if (dom === 'tiktok.com')    url = `https://www.tiktok.com/@${handle}`;
          else if (dom === 'youtube.com')   url = `https://www.youtube.com/${handle.startsWith('@') ? '' : '@'}${handle}`;
          else if (dom === 'facebook.com')  url = fbProfileId ? `https://www.facebook.com/${fbProfileId}` : `https://www.facebook.com/${handle}`;
          else if (dom === 'mastodon.social') url = `https://mastodon.social/search?q=${enc}`;
          else if (dom === 'threads.net')   url = `https://www.threads.net/@${handle}`;
          else if (dom === 'bsky.app')      url = `https://bsky.app/profile/${handle}`;
          else if (dom === 'google.com')    url = `https://www.google.com/search?q="${enc}"`;
          else url = baseTemplate.replace('{{DOM}}', inst.domain).replace('{{USER}}', handle);
        }
      }

      row.appendChild(createLink(url, inst.name, useIndividualColors ? getSocialColor(inst.name) : labelColor));
      if (idx < instances.length - 1) {
        const sep = document.createElement('span');
        sep.textContent = '·'; sep.style.color = 'rgba(255,255,255,0.15)';
        row.appendChild(sep);
      }
    });
    return row;
  }

  // ─────────────────────────────────────────────
  // 9. BARRE UI
  //    Créée une fois. Rebuild uniquement si l'URL
  //    a réellement changé depuis le dernier rendu.
  // ─────────────────────────────────────────────
  let bar       = null;
  let lastBuilt = null;

  function createBar() {
    bar = document.createElement('div');
    bar.id = 'omni-bar';
    bar.style.cssText = [
      `position:${isFixed ? 'fixed' : 'absolute'}`,
      // Placement Dynamique
      `${CFG.anchor_y}:${CFG.pos_y}`,
      `${CFG.anchor_x}:${CFG.pos_x}`,
      // Echelle et Point d'ancrage du redimensionnement
      `transform:scale(${CFG.scale_val})`,
      `transform-origin:${CFG.anchor_y} ${CFG.anchor_x}`,
      // Reste du design
      'z-index:999999',
      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
      `font-size:${CFG.fontSize}`,
      'font-weight:bold',
      'display:none',
      'flex-direction:column',
      'gap:3px',
      `background-color:rgba(5,5,5,${CFG.opacity})`,
      'padding:5px 8px',
      'border-radius:8px',
      'box-shadow:0 4px 15px rgba(0,0,0,0.6)',
      'backdrop-filter:blur(6px)',
      'color:#ffffff',
      'border:1px solid rgba(255,255,255,0.08)',
    ].join(';');
    document.body.appendChild(bar);
  }

  function updateBar(ctx) {
    if (!bar) return;

    if (!masterEnabled || document.fullscreenElement || ctx.site === 'unknown') {
      bar.style.display = 'none';
      return;
    }

    const currentUrl = location.href;
    if (currentUrl === lastBuilt) return;
    lastBuilt = currentUrl;

    bar.innerHTML = '';

    if (ctx.searchName && SOCIALS.length) {
      const searchRow = document.createElement('div');
      searchRow.style.cssText = 'display:flex;align-items:center;flex-wrap:wrap;max-width:420px;gap:3px;border-bottom:1px solid rgba(255,255,255,0.1);padding-bottom:4px;margin-bottom:1px';
      SOCIALS.forEach(link => {
        const url = link.urlPattern.replace('{{USER}}', encodeURIComponent(ctx.searchName));
        searchRow.appendChild(createLink(url, link.name, getSocialColor(link.name)));
      });
      bar.appendChild(searchRow);
    }

    if (CFG.show_rx === 'true' && INST.rx.length) bar.appendChild(createRow(INST.rx, ctx, 'https://{{DOM}}/{{USER}}',        'RX:',  '#ffffff', true));
    if (CFG.show_x  === 'true' && INST.x.length)  bar.appendChild(createRow(INST.x,  ctx, 'https://{{DOM}}/{{USER}}',        'X:',   '#ff8800', false));
    if (CFG.show_yt === 'true' && INST.yt.length)  bar.appendChild(createRow(INST.yt, ctx, 'https://{{DOM}}/{{USER}}',        'YT:',  '#ff3333', false));
    if (CFG.show_ig === 'true' && INST.ig.length)  bar.appendChild(createRow(INST.ig, ctx, 'https://{{DOM}}/user/{{USER}}',   'IG:',  '#e1306c', false));
    if (CFG.show_tt === 'true' && INST.tt.length)  bar.appendChild(createRow(INST.tt, ctx, 'https://{{DOM}}/@{{USER}}',       'TT:',  '#00f2fe', false));
    if (CFG.show_tw === 'true' && INST.tw.length)  bar.appendChild(createRow(INST.tw, ctx, 'https://{{DOM}}/{{USER}}',        'TW:',  '#9146ff', false));
    if (CFG.show_lt === 'true' && INST.lt.length)  bar.appendChild(createRow(INST.lt, ctx, 'https://{{DOM}}/{{USER}}',        'BIO:', '#39e09b', false));
    if (CUSTOM1) bar.appendChild(createRow(CUSTOM1.instances, ctx, CUSTOM1.template, `${CUSTOM1.label}:`, CUSTOM1.color, false));
    if (CUSTOM2) bar.appendChild(createRow(CUSTOM2.instances, ctx, CUSTOM2.template, `${CUSTOM2.label}:`, CUSTOM2.color, false));

    bar.style.display = bar.children.length ? 'flex' : 'none';
  }

  // ─────────────────────────────────────────────
  // 10. NAVIGATION SPA — zéro polling
  //     fixTitle() appelé AVANT getContext() :
  //     le titre corrigé est disponible pour searchName.
  //     Fallback à +600ms si searchName était vide.
  // ─────────────────────────────────────────────
  let navTimer      = null;
  let fallbackTimer = null;
  let lastUrl       = location.href;

  function onNavigate() {
    const newUrl = location.href;
    if (newUrl === lastUrl) return;
    lastUrl   = newUrl;
    lastBuilt = null;

    clearTimeout(navTimer);
    clearTimeout(fallbackTimer);

    navTimer = setTimeout(() => {
      fixTitle();                      // ← corrige le titre avant de lire le contexte
      const ctx = getContext();
      updateBar(ctx);
      if (!ctx.searchName) {
        fallbackTimer = setTimeout(() => {
          lastBuilt = null;
          fixTitle();                  // ← second essai pour les SPAs lentes
          updateBar(getContext());
        }, 600);
      }
    }, 600);
  }

  const _push    = history.pushState.bind(history);
  const _replace = history.replaceState.bind(history);
  history.pushState    = (...a) => { _push(...a);    onNavigate(); };
  history.replaceState = (...a) => { _replace(...a); onNavigate(); };
  window.addEventListener('popstate', onNavigate);

  document.addEventListener('fullscreenchange', () => {
    if (!bar) return;
    bar.style.display = document.fullscreenElement ? 'none' : (bar.children.length ? 'flex' : 'none');
  });

  // ─────────────────────────────────────────────
  // 11. DASHBOARD MODAL
  // ─────────────────────────────────────────────
  function openDashboard() {
    if (document.getElementById('omni-dashboard')) return;

    const backdrop = document.createElement('div');
    backdrop.id = 'omni-dashboard';
    backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.75);z-index:9999999;display:flex;align-items:center;justify-content:center;font-family:sans-serif';

    const win = document.createElement('div');
    win.style.cssText = 'background:#16161a;border:1px solid #2a2a35;border-radius:12px;width:650px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.5);color:#fff;overflow:hidden';

    const header = document.createElement('div');
    header.textContent = '⚙️ OMNI v7.2.2 — Platform Manager';
    header.style.cssText = 'padding:15px;border-bottom:1px solid #2a2a35;font-weight:bold;font-size:14px;background:#1e1e24';
    win.appendChild(header);

    const formArea = document.createElement('div');
    formArea.style.cssText = 'padding:15px;overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:12px';

    const fields = [
      { key: 'scale_val', label: '— Taille globale (ex: 1.0, 0.8 pour 80%)', type: 'text' },
      { key: 'anchor_y',  label: '— Ancrage Vertical (top ou bottom)',       type: 'text' },
      { key: 'pos_y',     label: '— Position Verticale (ex: 55px, 5%)',      type: 'text' },
      { key: 'anchor_x',  label: '— Ancrage Horizontal (left ou right)',     type: 'text' },
      { key: 'pos_x',     label: '— Position Horizontale (ex: 20px, 2%)',    type: 'text' },
      { key: 'socials',   label: '1. Search bar (NAME:URL,...)',             type: 'textarea' },
      { key: 'rx_inst',   label: '2. RX row (Facebook, Mastodon…)',          type: 'text' },
      { key: 'x_inst',    label: '3. X / Twitter row',                       type: 'text' },
      { key: 'yt_inst',   label: '4. YouTube row',                           type: 'text' },
      { key: 'ig_inst',   label: '5. Instagram / mirrors row',               type: 'text' },
      { key: 'tt_inst',   label: '6. TikTok row',                            type: 'text' },
      { key: 'tw_inst',   label: '7. Twitch row',                            type: 'text' },
      { key: 'lt_inst',   label: '8. Bio links row',                         type: 'text' },
      { key: 'custom_1',  label: 'Custom row 1',                             type: 'text' },
      { key: 'custom_2',  label: 'Custom row 2',                             type: 'text' },
      { key: 'title_fix', label: '— Smart Title Fix master (true/false)  ·  toggles par site : menu TM', type: 'text' },
    ];

    const ctrlStyle = 'width:100%;background:#22222b;border:1px solid #3a3a4a;border-radius:6px;color:#fff;padding:6px 8px;font-size:11px;box-sizing:border-box';

    fields.forEach(f => {
      const block = document.createElement('div');
      const lbl   = document.createElement('label');
      lbl.textContent = f.label;
      lbl.style.cssText = 'display:block;font-size:11px;color:#aaa;margin-bottom:4px;font-weight:bold';
      block.appendChild(lbl);
      const ctrl = document.createElement(f.type === 'textarea' ? 'textarea' : 'input');
      if (f.type !== 'textarea') ctrl.type = 'text';
      if (f.type === 'textarea') ctrl.style.height = '60px';
      ctrl.id    = 'omni_' + f.key;
      ctrl.value = CFG[f.key] || '';
      ctrl.style.cssText = ctrlStyle;
      block.appendChild(ctrl);
      formArea.appendChild(block);
    });
    win.appendChild(formArea);

    const footer = document.createElement('div');
    footer.style.cssText = 'padding:12px;border-top:1px solid #2a2a35;display:flex;justify-content:flex-end;gap:8px;background:#1e1e24';

    const cancel = document.createElement('button');
    cancel.textContent = 'Cancel';
    cancel.style.cssText = 'padding:6px 12px;background:#3a3a4a;border:none;border-radius:4px;color:#fff;cursor:pointer;font-size:11px';
    cancel.addEventListener('click', () => backdrop.remove());

    const save = document.createElement('button');
    save.textContent = '💾 Save & Reload';
    save.style.cssText = 'padding:6px 12px;background:#1877f2;border:none;border-radius:4px;color:#fff;cursor:pointer;font-size:11px;font-weight:bold';
    save.addEventListener('click', () => {
      fields.forEach(f => GM_setValue(f.key, document.getElementById('omni_' + f.key).value));
      backdrop.remove();
      location.reload();
    });

    footer.appendChild(cancel);
    footer.appendChild(save);
    win.appendChild(footer);
// LIGNE MANQUANTE : On attache la fenêtre au fond gris
    backdrop.appendChild(win);
    document.body.appendChild(backdrop);
  }

  // ─────────────────────────────────────────────
  // 12. MENUS TAMPERMONKEY
  // ─────────────────────────────────────────────
  GM_registerMenuCommand('⚙️ Open OMNI Manager', openDashboard);
  GM_registerMenuCommand(masterEnabled ? '🔴 Disable OMNI' : '🟢 Enable OMNI', () => { GM_setValue('master', masterEnabled ? 'false' : 'true'); location.reload(); });
  GM_registerMenuCommand(isFixed ? '🔓 Detach bar (absolute)' : '🔒 Fix bar (fixed)', () => { GM_setValue('fixed', isFixed ? 'false' : 'true'); location.reload(); });

  // Barre de navigation — toggles par ligne
  const toggles = [
    ['show_rx','RX'],['show_x','X/Twitter'],['show_yt','YouTube'],
    ['show_ig','Instagram'],['show_tt','TikTok'],['show_tw','Twitch'],['show_lt','Bio Links'],
  ];
  toggles.forEach(([k, label]) => {
    const on = CFG[k] === 'true';
    GM_registerMenuCommand(`${on ? '❌ Hide' : '✅ Show'} ${label} row`, () => { GM_setValue(k, on ? 'false' : 'true'); location.reload(); });
  });

  // Smart Title Fix — master toggle
  const titleFixOn = CFG.title_fix === 'true';
  GM_registerMenuCommand(
    `${titleFixOn ? '🔴 Disable' : '🟢 Enable'} Smart Title Fix`,
    () => { GM_setValue('title_fix', titleFixOn ? 'false' : 'true'); location.reload(); }
  );
  // Smart Title Fix — toggles par site (visibles seulement si master actif)
  if (titleFixOn) {
    const titleToggles = [
      ['title_fb','Facebook'],['title_ig','Instagram'],['title_x','X/Twitter'],
      ['title_th','Threads'],['title_tt','TikTok'],['title_tw','Twitch'],
      ['title_bsky','Bluesky'],['title_lt','Bio Links'],
    ];
    titleToggles.forEach(([k, label]) => {
      const on = CFG[k] === 'true';
      GM_registerMenuCommand(
        `  ${on ? '❌' : '✅'} Title: ${label}`,
        () => { GM_setValue(k, on ? 'false' : 'true'); location.reload(); }
      );
    });
  }

  // ─────────────────────────────────────────────
  // 13. INIT
  // ─────────────────────────────────────────────
  function init() {
    createBar();
    const titleBefore = document.title;
    updateBar(getContext());
    // Title fix avec délai : le DOM SPA peut ne pas être prêt au chargement initial
    setTimeout(() => {
      fixTitle();
      // Rebuild uniquement si le titre a réellement changé
      if (document.title !== titleBefore) {
        lastBuilt = null;
        updateBar(getContext());
      }
    }, 700);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();