Spotify Lyrics: Trad ⇄ Simplified

Convert Traditional⇄Simplified Chinese lyrics on Spotify fullscreen lyrics page

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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