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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==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();
})();