Spotify Lyrics: Trad ⇄ Simplified

Convert Traditional⇄Simplified Chinese lyrics on Spotify fullscreen lyrics page

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!)

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Spotify Lyrics: Trad ⇄ Simplified
// @namespace    https://holsoma.github.io/
// @version      1.1.0
// @description  Convert Traditional⇄Simplified Chinese lyrics on Spotify fullscreen lyrics page
// @author       holsoma
// @license      MIT
// @match        https://open.spotify.com/*
// @icon         https://open.spotify.com/favicon.ico
// @homepageURL  https://github.com/yourname/spotify-lyrics-converter
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/t2cn.js
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const LOG      = '[Lyrics ZH Converter]';
  const SEL_LINE = '[data-testid="lyrics-line"] .MmIREVIj8A2aFVvBZ2Ev';
  const SWEEP_MS = 350;

  // --- Converter (T->CN). Revert uses stored originals.
  let toS = s => s;
  (function setupConverter(){
    try {
      if (window.OpenCC?.t2cn)        { toS = s => OpenCC.t2cn(s); console.log(LOG, 'OpenCC.t2cn active'); }
      else if (window.t2cn)           { toS = s => t2cn(s);        console.log(LOG, 'global t2cn() active'); }
      else if (window.OpenCC?.Converter) { const c = OpenCC.Converter({from:'t',to:'cn'}); toS = s => c(s); console.log(LOG, 'OpenCC.Converter {t→cn} active'); }
      else                            { console.warn(LOG, 'No T→CN converter found; lyrics unchanged.'); }
    } catch (e) { console.warn(LOG, 'Converter setup error:', e); }
  })();

  // --- Cache original Traditional text per line
  function cacheOriginals(scope = document) {
    scope.querySelectorAll(SEL_LINE).forEach(el => {
      if (!el.hasAttribute('data-orig')) el.setAttribute('data-orig', (el.textContent || '').trim());
    });
  }

  // --- Rewrite helper
  function rewrite(toSimplified, scope = document) {
    cacheOriginals(scope);
    let changed = 0;
    scope.querySelectorAll(SEL_LINE).forEach(el => {
      const orig   = el.getAttribute('data-orig') ?? el.textContent ?? '';
      const target = toSimplified ? toS(orig) : orig;
      if (el.textContent !== target) { el.textContent = target; changed++; }
    });
    if (changed) console.log(LOG, `Rewrote ${changed} line(s) → ${toSimplified ? 'Simplified' : 'Traditional'}`);
    return changed;
  }

  // --- State + observers
  let isSimplified = true;
  let lyricsMO = null, pageMO = null, sweepTimer = null, btn = null;

  function hookLyricsObserver() {
    const host = document.querySelector('[data-testid="lyrics-line"]')?.parentElement;
    if (!host) return false;
    if (lyricsMO) lyricsMO.disconnect();

    lyricsMO = new MutationObserver(muts => {
      cacheOriginals(host);
      if (!isSimplified) return;
      // Convert only what changed for efficiency
      muts.forEach(m => {
        if (m.type === 'childList') {
          m.addedNodes.forEach(n => {
            if (n.nodeType === Node.ELEMENT_NODE) rewrite(true, n);
          });
        } else if (m.type === 'characterData') {
          const el = m.target.parentElement;
          if (el && el.matches(SEL_LINE)) {
            const orig = el.getAttribute('data-orig') ?? el.textContent ?? '';
            const target = toS(orig);
            if (el.textContent !== target) el.textContent = target;
          }
        }
      });
    });
    lyricsMO.observe(host, { subtree: true, childList: true, characterData: true });
    return true;
  }

  function startSweep() {
    if (sweepTimer) return;
    sweepTimer = setInterval(() => {
      cacheOriginals(document);
      if (isSimplified) rewrite(true, document);
    }, SWEEP_MS);
  }
  function stopSweep() { if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; } }

  // --- Mount the toggle INSIDE the lyrics container
  function mountToggle() {
    const container = document.querySelector('[data-testid="lyrics-line"]')?.parentElement;
    if (!container) return false;

    // Ensure positioning context so our absolute button sits bottom-right of panel
    const cs = getComputedStyle(container);
    if (cs.position === 'static') container.setAttribute('data-lyricszh-pos', container.style.position = 'relative');

    if (btn && btn.isConnected) btn.remove();
    btn = document.createElement('button');
    btn.textContent = '繁 / 简';
    btn.setAttribute('data-lyricszh-toggle', '1');
    Object.assign(btn.style, {
      position: 'absolute',
      right: '8px',
      bottom: '8px',
      padding: '6px 10px',
      fontSize: '12px',
      fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
      color: '#fff',
      background: 'rgba(30,30,30,.55)',
      border: '1px solid rgba(255,255,255,.28)',
      borderRadius: '8px',
      cursor: 'pointer',
      zIndex: 2147483647,
      backdropFilter: 'blur(4px)',
      userSelect: 'none'
    });
    btn.title = 'Toggle Traditional / Simplified (Alt+L)';

    btn.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      isSimplified = !isSimplified;
      if (isSimplified) { rewrite(true);  startSweep();  btn.style.background = 'rgba(30,30,30,.55)'; }
      else              { rewrite(false); stopSweep();   btn.style.background = 'rgba(80,80,80,.55)'; }
    });

    // Initial visual state
    btn.style.background = isSimplified ? 'rgba(30,30,30,.55)' : 'rgba(80,80,80,.55)';

    container.appendChild(btn);
    return true;
  }

  // --- Hotkey: Alt+L
  window.addEventListener('keydown', e => {
    if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'l' || e.key === 'L')) {
      const el = document.activeElement;
      if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return;
      isSimplified = !isSimplified;
      if (isSimplified) { rewrite(true);  startSweep(); }
      else              { rewrite(false); stopSweep(); }
      e.preventDefault(); e.stopPropagation();
    }
  }, true);

  // --- Boot
  function boot() {
    if (!/open\.spotify\.com$/.test(location.hostname)) return;

    if (pageMO) pageMO.disconnect();
    pageMO = new MutationObserver(() => {
      if (document.querySelector(SEL_LINE)) {
        cacheOriginals();
        hookLyricsObserver();
        if (!btn) mountToggle(); else if (!btn.isConnected) mountToggle();
        if (isSimplified) { rewrite(true); startSweep(); }
      }
    });
    pageMO.observe(document.documentElement, { subtree: true, childList: true });

    if (document.querySelector(SEL_LINE)) {
      cacheOriginals();
      hookLyricsObserver();
      mountToggle();
      if (isSimplified) { rewrite(true); startSweep(); }
    }

    // tiny debug
    window.__lyricsZH = {
      mode: () => (isSimplified ? 'Simplified' : 'Traditional'),
      remount: () => mountToggle(),
    };

    console.log(LOG, 'Initialized (anchored toggle).');
  }

  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();
})();