Spotify Lyrics: Trad ⇄ Simplified

Convert Traditional⇄Simplified Chinese lyrics on Spotify fullscreen lyrics page

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 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();
})();