Twitch - Show Stream Language

Displays the stream language as [EN]/[JA]/etc. Configurable, with two UI modes: a badge on the stream preview or a suffix next to the streamer’s username

// ==UserScript==
// @name         Twitch - Show Stream Language
// @namespace    twitch-language-suffix
// @version      1.5.4
// @description  Displays the stream language as [EN]/[JA]/etc. Configurable, with two UI modes: a badge on the stream preview or a suffix next to the streamer’s username
// @author       Vikindor (https://vikindor.github.io/)
// @homepageURL  https://github.com/Vikindor/twitch-show-stream-language
// @supportURL   https://github.com/Vikindor/twitch-show-stream-language/issues
// @license      MIT
// @match        https://www.twitch.tv/*
// @grant        none
// @inject-into  page
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ----------- CONFIG -----------
  // VISUAL_MODE options:
  // - 'suffix' : adds label next to the streamer's username
  // - 'badge'        : adds small pill in the top-right corner of the preview card
  const VISUAL_MODE = 'suffix';

  // ----------- DATA -----------
  const langByLogin = new Map();
  const idByLogin = new Map();
  const loginById = new Map();
  const langById = new Map();
  const toUpperCode = (v) => (typeof v === 'string' ? v.trim().toUpperCase() : null);
  const isIsoLike = (v) => typeof v === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(v.trim());

  const tagNameToCode = new Map(Object.entries({
    'arabic': 'AR','qatar': 'AR','uae': 'AR','العربية': 'AR','bulgarian': 'BG','български': 'BG',
    'czech': 'CS','cz': 'CS','czsk': 'CS','čeština': 'CS','danish': 'DA','dansk': 'DA','deutsch': 'DE',
    'greek': 'EL','ελληνικά': 'EL','australia': 'EN','english': 'EN','español': 'ES','espanol': 'ES',
    'suomi': 'FI','francais': 'FR','français': 'FR','magyar': 'HU','italiano': 'IT','日本語': 'JA',
    '한국어': 'KO','lietuva': 'LT','lithuania': 'LT','dutch': 'NL','nederlands': 'NL','norsk': 'NO',
    'polski': 'PL','portugues': 'PT','português': 'PT','portuguese': 'PT','romania': 'RO',
    'romanian': 'RO','română': 'RO','русский': 'RU','slovenčina': 'SK','svenska': 'SV','ภาษาไทย': 'TH',
    'tagalog': 'TL','turkish': 'TR','türkçe': 'TR','ukrainian': 'UK','українська': 'UK',
    '中文': 'ZH','中文(简体)': 'ZH','中文(繁體)': 'ZH'
  }));

  function tagToCode(tagObj) {
    if (!tagObj) return null;
    if (typeof tagObj === 'string') return tagNameToCode.get(tagObj.trim().toLowerCase()) || null;
    const name = tagObj.localizedName || tagObj.name || tagObj.tagName || tagObj.label || tagObj.slug;
    return name ? (tagNameToCode.get(String(name).trim().toLowerCase()) || null) : null;
  }

  function extractPair(node) {
    if (!node || typeof node !== 'object') return null;

    const login =
      (node.broadcaster && (node.broadcaster.login || node.broadcasterLogin)) ||
      node.userLogin ||
      node.login ||
      (node.channel && (node.channel.login || node.channel.name)) ||
      null;

    let lang = null;
    if (typeof node.broadcasterLanguage === 'string' && node.broadcasterLanguage) lang = node.broadcasterLanguage;
    if (!lang && typeof node.language === 'string' && isIsoLike(node.language)) lang = node.language;
    if (!lang && node.stream && typeof node.stream.language === 'string' && isIsoLike(node.stream.language)) lang = node.stream.language;
    if (!lang && node.channel) {
      const ch = node.channel;
      if (typeof ch.broadcasterLanguage === 'string' && isIsoLike(ch.broadcasterLanguage)) lang = ch.broadcasterLanguage;
      else if (typeof ch.language === 'string' && isIsoLike(ch.language)) lang = ch.language;
    }
    if (!lang) {
      const tags = Array.isArray(node.contentTags) ? node.contentTags :
                   Array.isArray(node.freeformTags) ? node.freeformTags : null;
      if (tags) {
        for (const t of tags) { const c = tagToCode(t); if (c) { lang = c; break; } }
      }
    }

    if (login && lang) return { login: String(login).toLowerCase(), lang: toUpperCode(lang) };
    return null;
  }

  function extractTriple(node) {
    if (!node || typeof node !== 'object') return null;

    const login =
      (node.broadcaster && (node.broadcaster.login || node.broadcasterLogin)) ||
      (node.user && node.user.login) ||
      (node.userByAttribute && node.userByAttribute.login) ||
      node.userLogin ||
      node.login ||
      (node.channel && (node.channel.login || node.channel.name)) ||
      null;

    const id =
      (node.user && node.user.id) ||
      (node.userByAttribute && node.userByAttribute.id) ||
      (node.channel && node.channel.id) ||
      (node.broadcaster && node.broadcaster.id) ||
      node.id || null;

    let lang = null;
    if (typeof node.broadcasterLanguage === 'string' && node.broadcasterLanguage) lang = node.broadcasterLanguage;
    if (!lang && typeof node.language === 'string' && isIsoLike(node.language)) lang = node.language;
    if (!lang && node.stream && typeof node.stream.language === 'string' && isIsoLike(node.stream.language)) lang = node.stream.language;
    if (!lang && node.broadcastSettings && typeof node.broadcastSettings.language === 'string') lang = node.broadcastSettings.language;
    if (!lang && node.channel) {
      const ch = node.channel;
      if (typeof ch.broadcasterLanguage === 'string' && isIsoLike(ch.broadcasterLanguage)) lang = ch.broadcasterLanguage;
      else if (typeof ch.language === 'string' && isIsoLike(ch.language)) lang = ch.language;
    }
    const outLogin = login ? String(login).toLowerCase() : null;
    const outLang  = lang ? toUpperCode(lang) : null;
    if (outLogin || id || outLang) return { login: outLogin, id, lang: outLang };
    return null;
  }

  function collectLanguages(any) {
    if (!any || typeof any !== 'object') return;

    const pair = extractPair(any);
    if (pair) {
      const prev = langByLogin.get(pair.login);
      if (prev !== pair.lang) {
        langByLogin.set(pair.login, pair.lang);
        queueAnnotate();
      }
    }

    const triple = extractTriple(any);
    if (triple) {
      let touched = false;

      if (triple.login && triple.id) {
        if (idByLogin.get(triple.login) !== triple.id) { idByLogin.set(triple.login, triple.id); touched = true; }
        if (loginById.get(triple.id) !== triple.login) { loginById.set(triple.id, triple.login); touched = true; }
      }
      if (triple.lang) {
        if (triple.id && langById.get(triple.id) !== triple.lang) { langById.set(triple.id, triple.lang); touched = true; }
        if (triple.login && !langByLogin.has(triple.login)) { langByLogin.set(triple.login, triple.lang); touched = true; }

        if (!triple.login && triple.id) {
          const knownLogin = loginById.get(triple.id);
          if (knownLogin && !langByLogin.get(knownLogin)) { langByLogin.set(knownLogin, triple.lang); touched = true; }
        }
        if (!triple.id && triple.login) {
          const knownId = idByLogin.get(triple.login);
          if (knownId && !langById.get(knownId)) { langById.set(knownId, triple.lang); touched = true; }
        }
      }
      if (touched) queueAnnotate();
    }

    if (Array.isArray(any)) {
      for (const it of any) collectLanguages(it);
    } else {
      for (const k in any) {
        if (!Object.prototype.hasOwnProperty.call(any, k)) continue;
        const v = any[k];
        if (v && typeof v === 'object') collectLanguages(v);
      }
    }
  }

  const origFetch = window.fetch;
  window.fetch = function (...args) {
    const p = origFetch.apply(this, args);
    try {
      const url = String(args[0] || '');
      if (url.includes('/gql')) {
        p.then((res) => { res.clone().json().then(collectLanguages).catch(()=>{}); }).catch(()=>{});
      }
    } catch {}
    return p;
  };

  const OrigXHR = window.XMLHttpRequest;
  window.XMLHttpRequest = function PatchedXHR() {
    const xhr = new OrigXHR();
    let isGQL = false;
    const origOpen = xhr.open;
    xhr.open = function (method, url, ...rest) {
      isGQL = url && /\/gql(\?|$)/.test(String(url));
      return origOpen.call(this, method, url, ...rest);
    };
    xhr.addEventListener('load', function () {
      if (!isGQL) return;
      try {
        const ct = (xhr.getResponseHeader('content-type') || '').toLowerCase();
        if (!ct.includes('application/json')) return;
        collectLanguages(JSON.parse(xhr.responseText));
      } catch {}
    });
    return xhr;
  };

  const ANY_LINK_SELECTORS = [
    'a[data-a-target="preview-card-title-link"]',
    'a[data-a-target="preview-card-channel-link"]',
    'a[data-test-selector="preview-card-title-link"]',
    'a[data-test-selector="preview-card-channel-link"]',
    'a[data-test-selector="TitleAndChannel__titleLink"]',
    'a[data-test-selector="TitleAndChannel__channelLink"]',
  ].join(',');

  const CHANNEL_LINK_SELECTORS = [
    'a[data-a-target="preview-card-channel-link"]',
    'p[data-a-target="preview-card-channel-link"]',
    'a[data-test-selector="preview-card-channel-link"]',
    'a[data-test-selector="TitleAndChannel__channelLink"]',
    'p[data-test-selector="TitleAndChannel__channelLink"]',
  ].join(',');

  function getLoginFromLink(node) {
    const a = node.tagName === 'A' ? node : node.closest('a[href^="/"]');
    if (!a) return null;
    const href = a.getAttribute('href') || '';
    const m = href.match(/^\/([a-zA-Z0-9_]+)(?:\/|$)/);
    return m ? m[1].toLowerCase() : null;
  }

  function inferLanguageFromText(text) {
    if (!text) return null;
    const t = text.replace(/https?:\/\/\S+/g, '');
    if (/[ㄱ-ㅎ가-힣]/.test(t)) return 'KO';
    if (/[\u3040-\u309F]/.test(t) || /[\u30A0-\u30FF]/.test(t)) return 'JA';
    if (/[\u4E00-\u9FFF]/.test(t)) return 'ZH';
    if (/[\u0600-\u06FF]/.test(t)) return 'AR';
    if (/[\u0590-\u05FF]/.test(t)) return 'HE';
    if (/[\u0E00-\u0E7F]/.test(t)) return 'TH';
    if (/[\u0900-\u097F]/.test(t)) return 'HI';
    return null;
  }

  function inferLangFromCard(card) {
    try {
      const titled = card.querySelector('h4[title], h3[title], p[title]');
      const titleFromAttr = titled ? titled.getAttribute('title') : '';
      if (titleFromAttr) return inferLanguageFromText(titleFromAttr);

      const titleEl =
        card.querySelector('a[data-a-target="preview-card-title-link"]') ||
        card.querySelector('a[data-test-selector="preview-card-title-link"]') ||
        card.querySelector('[data-test-selector="TitleAndChannel__title"]');

      const title = titleEl ? titleEl.textContent : '';
      return inferLanguageFromText(title);
    } catch (_) {
      return null;
    }
  }

  function getCurrentLogin() {
    const m = location.pathname.match(/^\/([a-zA-Z0-9_]+)(?:\/|$)/);
    return m ? m[1].toLowerCase() : null;
  }

  function getInlineEl(mode) {
    const el = document.createElement('span');
    el.className = '__langChannelInline';
    el.style.marginLeft = '0.2rem';
    el.style.verticalAlign = 'middle';
    el.style.pointerEvents = 'none';
    el.style.fontWeight = '700';

    if (mode === 'badge') {
      el.style.padding = '2px 6px';
      el.style.borderRadius = '4px';
      el.style.fontSize = '12px';
      el.style.lineHeight = '16px';
      el.style.background = 'rgb(235,4,0)';
      el.style.color = '#fff';
    } else {
      el.style.whiteSpace = 'nowrap';
      el.style.opacity = '0.9';
      el.style.color = 'rgb(162,126,217)';
    }
    el.textContent = '[??]';
    return el;
  }

  function ensureChannelHeaderLang(root) {
    const section =
      root.querySelector('section#live-channel-stream-information') ||
      root.querySelector('section[id="live-channel-stream-information"]');
    if (!section) return;

    const h1 = section.querySelector('h1');
    if (!h1) return;

    const verifiedSvg = section.querySelector('svg[aria-label*="Verified" i]');
    const verifiedBox = verifiedSvg ? verifiedSvg.closest('[class]') : null;

    const nameLink = (h1.closest && h1.closest('a[href^="/"]')) || null;
    const ref = verifiedBox || nameLink;
    if (!ref || !ref.parentElement) return;

    const parent = ref.parentElement;
    let container = parent.querySelector(':scope > .__langChannelInline');

    const oldSpan = parent.querySelector(':scope > span.__langChannelInline');
    if (!container) {
      container = document.createElement('div');
      container.className = '__langChannelInline';
      parent.insertBefore(container, ref.nextSibling);

      if (oldSpan) {
        oldSpan.classList.remove('__langChannelInline');
        container.appendChild(oldSpan);
      } else {
        const inner = getInlineEl(VISUAL_MODE);
        inner.classList.remove('__langChannelInline');
        container.appendChild(inner);
      }
    } else {
      if (container.previousSibling !== ref || container.parentElement !== parent) {
        parent.insertBefore(container, ref.nextSibling);
      }
      if (!container.firstElementChild && !container.textContent.trim()) {
        const inner = getInlineEl(VISUAL_MODE);
        inner.classList.remove('__langChannelInline');
        container.appendChild(inner);
      }
    }

    const login = getCurrentLogin();
    const displayEl = container.firstElementChild || container;
    let code = login ? langByLogin.get(login) : null;
    if (!code && login) {
      const id = idByLogin.get(login);
      if (id) code = langById.get(id) || null;
    }
    displayEl.textContent = `[${code || '??'}]`;
  }

  function ensureRightSuffix(node, login) {
    const card = node.closest('article,[data-target="directory-first-item"]') || node;

    let row = node.parentElement || node;
    if (row && row.nextElementSibling && row.parentElement) {
      row = row.parentElement;
    }

    let badge = row.querySelector('.__langSuffixRight');
    if (!badge) {
      badge = document.createElement('div');
      badge.className = '__langSuffixRight';
      badge.style.marginLeft = 'auto';
      badge.style.whiteSpace = 'nowrap';
      badge.style.fontWeight = '600';
      badge.style.opacity = '0.9';
      badge.style.order = '999';
      row.appendChild(badge);
    }
    badge.style.color = 'rgb(162,126,217)';
    badge.style.pointerEvents = 'none';

    let code = langByLogin.get(login);
    if (!code) {
      const h = inferLangFromCard(card);
      if (h) code = h;
    }
    badge.textContent = `[${code || '??'}]`;

    card.querySelectorAll('.__langSuffixRight').forEach((el) => {
      if (el !== badge && el.parentElement !== row) el.remove();
    });
  }

  function ensureBadge(a, login) {
    const article = a.closest('article') || a.closest('div[data-target="directory-first-item"]') || a.closest('div') || a;

    const thumb =
      article.querySelector('[data-a-target="preview-card-image-link"]') ||
      article.querySelector('[data-a-target="preview-card-thumbnail"]') ||
      article.querySelector('figure') ||
      article;

    const id = '__langBadge';
    if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative';

    let el = thumb.querySelector(`.${id}`);
    if (!el) {
      el = document.createElement('div');
      el.className = id;
      el.style.position = 'absolute';
      el.style.top = '8px';
      el.style.right = '8px';
      el.style.padding = '2px 6px';
      el.style.borderRadius = '4px';
      el.style.fontSize = '12px';
      el.style.fontWeight = '700';
      el.style.lineHeight = '16px';
      el.style.background = 'rgb(235,4,0)';
      el.style.color = '#fff';
      el.style.pointerEvents = 'none';
      el.style.zIndex = '3';
      el.textContent = '[??]';
      thumb.appendChild(el);
    }
    let code = langByLogin.get(login);
    if (!code) {
      const h = inferLangFromCard(article);
      if (h) code = h;
    }
    el.textContent = `[${code || '??'}]`;
  }

  function annotate(root = document) {
    ensureChannelHeaderLang(root);

    if (VISUAL_MODE === 'suffix') {
      let nodes = root.querySelectorAll(
        'p[data-a-target="preview-card-channel-link"], p[data-test-selector="TitleAndChannel__channelLink"]'
      );
      if (nodes.length === 0) {
        nodes = root.querySelectorAll(
          'a[data-a-target="preview-card-channel-link"], a[data-test-selector="preview-card-channel-link"], a[data-test-selector="TitleAndChannel__channelLink"]'
        );
      }
      nodes.forEach((n) => {
        const login = getLoginFromLink(n);
        if (!login) return;
        ensureRightSuffix(n, login);
      });
    } else {
      const links = root.querySelectorAll(ANY_LINK_SELECTORS);
      links.forEach((a) => {
        const login = getLoginFromLink(a);
        if (!login) return;
        ensureBadge(a, login);
      });
    }
  }

  let raf = null;
  function queueAnnotate() {
    if (raf) cancelAnimationFrame(raf);
    raf = requestAnimationFrame(() => annotate(document));
  }

  const mo = new MutationObserver((muts) => {
    for (const m of muts) {
      if (!m.addedNodes || !m.addedNodes.length) continue;
      for (const n of m.addedNodes) if (n.nodeType === 1) annotate(n);
    }
  });

  function start() {
    try { mo.observe(document.documentElement, { childList: true, subtree: true }); } catch {}
    queueAnnotate();
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();