Spotify Lyrics: Trad ⇄ Simplified

Convert Traditional⇄Simplified Chinese lyrics on Spotify fullscreen lyrics page

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();