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.10
// @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      GPL-3.0
// @match        https://www.twitch.tv/*
// @grant        none
// @inject-into  page
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ---------------- CONFIG ----------------
  // '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';
  // ----------------------------------------

  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);
      }
    }
  }

  function getFetchUrl(input) {
    if (typeof input === 'string') return input;
    if (input && typeof input.url === 'string') return input.url;
    return '';
  }

  const origFetch = window.fetch;
  window.fetch = function (...args) {
    const p = origFetch.apply(this, args);
    try {
      const url = getFetchUrl(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(',');

  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;
    const primaryNode =
      card.querySelector('p[data-a-target="preview-card-channel-link"], p[data-test-selector="TitleAndChannel__channelLink"]') ||
      node;

    let row = primaryNode.parentElement || primaryNode;
    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') {
      const nodes = root.querySelectorAll(
        'p[data-a-target="preview-card-channel-link"], p[data-test-selector="TitleAndChannel__channelLink"], 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();
})();