YouTube Music / Spotify 网易云歌词显示

为 YTM 和 Spotify 添加网易云歌词 (带有翻译或者罗马音显示)

// ==UserScript==
// @name         YouTube Music / Spotify 网易云歌词显示
// @version      0.22
// @description  为 YTM 和 Spotify 添加网易云歌词 (带有翻译或者罗马音显示)
// @author       Hyun
// @license      MIT
// @match        *://music.youtube.com/*
// @match        *://open.spotify.com/*
// @icon         https://music.163.com/favicon.ico
// @connect      interface.music.163.com
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.addElement
// @grant        unsafeWindow
// @require      https://fastly.jsdelivr.net/npm/[email protected]/crypto-js.min.js
// @run-at       document-end
// @inject-into  content
// @namespace    https://greasyfork.org/users/718868
// ==/UserScript==

const EAPI_AES_KEY = 'e82ckenh8dichen8';
const EAPI_ENCODE_KEY = '3go8&$8*3*3h0k(2)2';
const EAPI_CHECK_TOKEN = '9ca17ae2e6ffcda170e2e6ee8ad85dba908ca4d74da9ac8ea2d44e938f9eadc66da5a8979af572a5a9b68ac12af0feaec3b92aa69af9b1d372f6b8adccb35e968b9bb6c14f908d0099fb6ff48efdacd361f5b6ee9e';
const EAPI_BASE_HEADERS = {
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) NeteaseMusicDesktop/3.0.14.2534',
};
const EAPI_BASE_COOKIES = {
  "os": "osx",
  "appver": "3.0.14",
  "requestId": 0,
  "osver": "15.6.1",
};

// Setup Trusted Types policy
window.trustedTypes && window.trustedTypes.createPolicy('default', {
  createScriptURL: s => s,
  createScript: s => s,
  createHTML: s => s
});

// A simple cookie jar using GM storage
class CookieJar {
  constructor(storageKey) {
    this.storageKey = storageKey;
  }

  async get(url) {
    const allCookies = await GM.getValue(this.storageKey, {});
    const now = Date.now();
    const { hostname, pathname } = new URL(url);
    const matchingCookies = [];
    let needsSave = false;

    for (const domain in allCookies) {
      if (!hostname.endsWith(domain)) continue;
      const domainJar = allCookies[domain];
      for (const path in domainJar) {
        if (!pathname.startsWith(path)) continue;
        const pathJar = domainJar[path];
        for (const name in pathJar) {
          const cookie = pathJar[name];
          if (cookie.expires && cookie.expires < now) {
            delete pathJar[name];
            needsSave = true;
          } else {
            matchingCookies.push(`${name}=${cookie.value}`);
          }
        }
        if (Object.keys(pathJar).length === 0) delete domainJar[path];
      }
      if (Object.keys(domainJar).length === 0) delete allCookies[domain];
    }

    if (needsSave) await GM.setValue(this.storageKey, allCookies);
    return matchingCookies.join('; ');
  }

  async set(url, setCookieHeader) {
    if (!setCookieHeader) return;
    const allCookies = await GM.getValue(this.storageKey, {});
    const { hostname } = new URL(url);

    const parts = setCookieHeader.split(';').map(p => p.trim());
    const [name, value] = parts[0].split('=').map(s => s.trim());
    if (!name) return;

    const cookie = { value };
    let domain = hostname;
    let path = '/';

    parts.slice(1).forEach(part => {
      let [key, val] = part.split('=').map(s => s.trim());
      switch (key.toLowerCase()) {
        case 'expires': cookie.expires = new Date(val).getTime(); break;
        case 'max-age': cookie.expires = Date.now() + (parseInt(val, 10) * 1000); break;
        case 'path': path = val; break;
        case 'domain': domain = val.startsWith('.') ? val.substring(1) : val; break;
      }
    });

    allCookies[domain] = allCookies[domain] || {};
    allCookies[domain][path] = allCookies[domain][path] || {};
    allCookies[domain][path][name] = cookie;

    await GM.setValue(this.storageKey, allCookies);
  }
}
const cookieJar = new CookieJar('lyrics.eapi.cookies');

// Netease Cloud Music API (EAPI)
const eapi = (path, options={}) => new Promise(async (resolve, reject) => {
  const { data = {}, headers = {}, header = {}, cookies = {}, params = {} } = options;
  Object.assign(header,  EAPI_BASE_COOKIES);
  Object.assign(headers, EAPI_BASE_HEADERS);
  Object.assign(cookies, EAPI_BASE_COOKIES);

  const queryStr = new URLSearchParams(params).toString();
  const url = `https://interface.music.163.com/eapi${path}${queryStr ? `?${queryStr}` : ''}`;
  const storedCookies = await cookieJar.get(url);
  if (storedCookies) {
    storedCookies.split('; ').forEach(c => {
      const [k, v] = c.split('=', 2);
      if (k) cookies[k] = v;
    });
  }

  data.header = JSON.stringify(header);
  const body = JSON.stringify(data);
  const sign = CryptoJS.MD5(`nobody/api${path}use${body}md5forencrypt`).toString();

  GM.xmlHttpRequest({
    url,
    method: 'POST',
    headers: {
      ...headers,
      'Content-Type': 'application/x-www-form-urlencoded',
      'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ')
    },
    data: `params=${encodeURIComponent(
      CryptoJS.AES.encrypt(
        `/api${path}-36cd479b6b5-${body}-36cd479b6b5-${sign}`,
        CryptoJS.enc.Utf8.parse(EAPI_AES_KEY),
        { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }
      ).toString(CryptoJS.format.Hex).toUpperCase())
    }`,
    responseType: 'json',
    anonymous: true,
    onerror: reject,
    ontimeout: reject,
    onload: async (response) => {
      if (response.responseHeaders) {
        const setCookieHeaders = response.responseHeaders.split('\r\n')
          .filter(h => h.toLowerCase().startsWith('set-cookie:'))
          .map(h => h.substring(h.indexOf(':') + 1).trim());

        for (const header of setCookieHeaders) {
          const cookieStrings = header.split(/,(?=\s*[^=;\s]+=)/);
          for (const cookieStr of cookieStrings) {
            if (cookieStr) await cookieJar.set(url, cookieStr.trim());
          }
        }
      }

      if (response.status >= 200 && response.status < 300) {
        const res = response.response;
        if (res.code !== 200) {
          reject(new Error(res.message));
          return;
        }
        resolve(res);
      } else {
        reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
      }
    }
  })
});

const cloudmusic = {
  register: (deviceId) => {
    const encode = (some_id) => {
      let xoredString = '';
      for (let i = 0; i < some_id.length; i++) {
        const charCode = some_id.charCodeAt(i) ^ EAPI_ENCODE_KEY.charCodeAt(i % EAPI_ENCODE_KEY.length);
        xoredString += String.fromCharCode(charCode);
      }
      const wordArray = CryptoJS.enc.Utf8.parse(xoredString);
      return btoa(`${some_id} ${CryptoJS.MD5(wordArray).toString(CryptoJS.enc.Base64)}`);
    }

    return eapi('/register/anonimous', {
      data: {
        username: encode(deviceId),
      },
      params: {
        '_nmclfl': '1'
      }
    });
  },
  search: (keyword, limit = 10) => eapi('/search/song/list/page', {
    data: {
      offset: '0',
      scene: 'NORMAL',
      needCorrect: 'true',
      checkToken: EAPI_CHECK_TOKEN,
      keyword,
      limit: limit.toString(),
      verifyId: 1
    },
    headers: {
      'X-Anticheattoken': EAPI_CHECK_TOKEN
    },
    params: {
      '_nmclfl': '1'
    }
  }),
  lyric: (id) => eapi('/song/lyric/v1', {
    data: {
      id, tv: "-1", yv: "-1", rv: "-1", lv: "-1",
      verifyId: 1
    },
    params: {
      '_nmclfl': '1'
    }
  })
}

// Floating Lyrics UI
const lyricsUI = {
  update(lyricsData) {},
  show() {},
  hide() {},
  tick(currentTimeMs) {},
  updateSources(sources) {},
  onSourceSelect(id) {},
};

async function injectUI() {
  const panelHTML = `
    <div id="lyric-float" aria-label="Floating lyrics panel" style="display: none;">
      <div class="drag-handle" id="lyric-dragHandle" title="Drag to move • Right-click or long-press for options">
        <span class="handle-dots" aria-hidden="true"></span>
        <span class="brand">Floating Lyrics</span>
      </div>
      <div class="lyrics-scroll">
        <div class="fade-top"></div>
        <div class="list" id="lyric-list"></div>
        <div class="fade-bottom"></div>
      </div>
      <div class="resize-handle" id="lyric-resizeHandle"></div>
    </div>
  `;

  const menuHTML = `
    <div class="ctx-menu" id="lyric-menu" role="menu" aria-hidden="true">
      <div class="menu-title">Settings</div>
      <div class="menu-group">
        <div class="menu-row">
          <div class="menu-label">Display</div>
          <div class="mode-wrap" id="lyric-modeWrap">
            <label class="radio"><input type="radio" name="mode" value="orig">Original only</label>
            <label class="radio"><input type="radio" name="mode" value="orig+trans">Original + Translation</label>
            <label class="radio"><input type="radio" name="mode" value="orig+roma">Original + Romanized</label>
          </div>
        </div>
      </div>
      <div class="menu-group">
        <div class="menu-row">
          <div class="menu-label">Source</div>
          <select id="lyric-sourceSelect"></select>
        </div>
      </div>
      <div class="menu-group">
        <div class="menu-row">
          <div class="menu-label">Font size</div>
          <button class="btn small" id="lyric-fontMinus">–</button>
          <input class="slider" id="lyric-fontSlider" type="range" min="16" max="64" step="1">
          <button class="btn small" id="lyric-fontPlus">+</button>
          <div class="value" id="lyric-fontValue">28px</div>
        </div>
      </div>
      <div class="menu-group">
        <div class="menu-row">
          <div class="menu-label">Offset</div>
          <div class="offset-buttons" id="lyric-offsetButtons">
            <button class="btn" data-delta="-500">−0.50s</button>
            <button class="btn" data-delta="-100">−0.10s</button>
            <button class="btn" data-reset="1">Reset</button>
            <button class="btn" data-delta="100">+0.10s</button>
            <button class="btn" data-delta="500">+0.50s</button>
          </div>
          <div class="value" id="lyric-offsetValue">+0.00s</div>
        </div>
        <div class="hint">Tip: Offsets shift when each line appears.</div>
      </div>
    </div>
  `;

  const styles = `
    :host {
      --font-size: 28px; --accent-1: #8be9fd; --accent-2: #ff79c6;
      --panel-bg: rgba(0, 0, 0, 0.8); --panel-br: rgba(255, 255, 255, 0.18);
      --shadow-1: rgba(0, 0, 0, 0.45); --shadow-2: rgba(0, 0, 0, 0.65);
      --handle-h: 22px;
    }
    #lyric-float {
      position: fixed; z-index: 99999; left: calc(50% - 300px); top: 18%;
      width: 600px; height: 280px; min-width: 260px; min-height: 140px;
      max-width: calc(100vw - 12px); max-height: calc(100vh - 12px);
      /* resize: both; */ overflow: hidden; border-radius: 16px; background: var(--panel-bg);
      border: 1px solid var(--panel-br); backdrop-filter: blur(14px) saturate(140%);
      -webkit-backdrop-filter: blur(14px) saturate(140%);
      box-shadow: 0 10px 30px var(--shadow-1), 0 30px 60px var(--shadow-2), inset 0 1px 0 rgba(255,255,255,0.08);
      user-select: none; transition: box-shadow 160ms ease;
    }
    #lyric-float.dragging {
      box-shadow: 0 6px 16px rgba(0,0,0,0.55), 0 20px 40px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06);
    }
    .drag-handle {
      position: absolute; inset: 0 0 auto 0; height: var(--handle-h); display: flex;
      align-items: center; gap: 6px; padding: 0 10px; cursor: move; color: rgba(255,255,255,0.7);
      font-size: 11px; letter-spacing: .3px;
      background: linear-gradient(to bottom, rgba(255,255,255,0.2), rgba(255,255,255,0));
      border-bottom: 1px solid rgba(255,255,255,0.06); touch-action: none;
    }
    .handle-dots {
      display: inline-block; width: 14px; height: 4px;
      background: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.6) 2px, transparent 3px), radial-gradient(circle at 7px 2px, rgba(255,255,255,0.35) 2px, transparent 3px), radial-gradient(circle at 12px 2px, rgba(255,255,255,0.2) 2px, transparent 3px);
      filter: drop-shadow(0 1px 1px rgba(0,0,0,0.4));
    }
    .brand { display: none; }
    .lyrics-scroll { position: absolute; inset: var(--handle-h) 0 0 0; overflow: hidden; }
    .resize-handle {
      position: absolute; right: 0; bottom: 0;
      width: 24px; height: 24px; cursor: se-resize;
      z-index: 10; touch-action: none;
    }
    .resize-handle::after {
      content: ''; position: absolute; right: 4px; bottom: 4px;
      width: 8px; height: 8px; border-right: 2px solid rgba(255,255,255,0.4);
      border-bottom: 2px solid rgba(255,255,255,0.4);
      border-bottom-right-radius: 3px; pointer-events: none;
    }
    .fade-top, .fade-bottom { position: absolute; left: 0; right: 0; pointer-events: none; }
    .fade-top { top: 0; height: 46px; background: linear-gradient(to bottom, rgba(5,7,15,0.9), rgba(5,7,15,0)); }
    .fade-bottom { bottom: 0; height: 46px; background: linear-gradient(to top, rgba(5,7,15,0.9), rgba(5,7,15,0)); }
    .list { position: absolute; inset: 0; overflow: auto; scroll-behavior: smooth; padding: 10px 20px 16px; }
    .group { padding: 4px 6px; }
    .line {
      font-size: var(--font-size); line-height: 1.35; color: rgba(255,255,255,0.55);
      text-align: center; padding: 2px 8px; transition: color 140ms ease, transform 200ms ease, opacity 200ms ease;
      text-shadow: 0 1px 0 rgba(0,0,0,0.5); white-space: pre-wrap; word-break: break-word;
    }
    .sub { font-size: calc(var(--font-size) * .72); opacity: .9; color: rgba(255,255,255,0.65); }
    .line.active {
      color: #fff; transform: scale(1.02);
      text-shadow: 0 2px 10px rgba(0,0,0,0.5), 0 0 22px rgba(139,233,253,0.25);
      background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
      -webkit-background-clip: text; background-clip: text; color: transparent;
      filter: drop-shadow(0 0 10px rgba(139,233,253,0.08));
    }
    .ctx-menu {
      position: fixed; z-index: 100000; min-width: 180px; padding: 8px 6px; border-radius: 10px;
      background: rgba(20, 24, 35, 0.96); border: 1px solid rgba(255,255,255,0.08);
      box-shadow: 0 16px 40px rgba(0,0,0,0.55); color: #eaeef7;
      backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); display: none;
      max-width: 92vw; max-height: calc(100vh - 16px); overflow: auto; box-sizing: border-box;
    }
    @media (pointer: coarse) {
      .ctx-menu.open {
        left: 0 !important; right: 0 !important; top: auto !important;
        bottom: calc(env(safe-area-inset-bottom) + 8px) !important;
        width: 100vw !important; max-width: 100vw !important; max-height: 60vh !important;
        border-radius: 14px 14px 0 0 !important;
      }
    }
    .ctx-menu.open { display: block; }
    .menu-title { margin: 2px 8px 6px; font-size: 12px; opacity: .7; letter-spacing: .3px; }
    .menu-group { padding: 4px 6px; }
    .menu-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; flex-wrap: wrap; }
    .menu-row + .menu-row { border-top: 1px dashed rgba(255,255,255,0.08); }
    .menu-label { width: 56px; font-size: 13px; opacity: .7; }
    .mode-wrap, .offset-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
    .btn {
      appearance: none; border: 1px solid rgba(255,255,255,0.15);
      background: linear-gradient(to bottom, rgba(255,255,255,0.08), rgba(255,255,255,0.02));
      color: #eaf1ff; padding: 4px 7px; border-radius: 7px; font-size: 12px; cursor: pointer;
      transition: transform 80ms ease, background 120ms ease, border-color 120ms ease;
    }
    .btn:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.25); }
    .btn:active { transform: translateY(0); }
    .btn.small { padding: 3px 6px; }
    .radio {
      display: inline-flex; align-items: center; gap: 8px; padding: 4px 8px;
      border-radius: 8px; border: 1px solid transparent; cursor: pointer; font-size: 12px;
    }
    .radio input { accent-color: #6ee7ff; }
    .radio.active { border-color: rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); }
    .slider { flex: 1; min-width: 0; }
    .value { font-variant-numeric: tabular-nums; opacity: .8; min-width: 40px; text-align: right; }
    .menu-row select, .menu-row input[type="range"] { flex: 1; min-width: 0; }
    .hint { opacity: .55; font-size: 12px; margin: 6px 8px 0; text-align: right; }
    .no-select, .no-select * { user-select: none !important; }
  `;

  const shadowHost = document.createElement('div');
  shadowHost.id = 'lyrics-shadow-host';
  const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
  document.body.appendChild(shadowHost);

  const styleEl = document.createElement('style');
  styleEl.textContent = styles;
  shadowRoot.appendChild(styleEl);

  shadowRoot.appendChild((new DOMParser().parseFromString(panelHTML, 'text/html')).body.firstChild);
  shadowRoot.appendChild((new DOMParser().parseFromString(menuHTML, 'text/html')).body.firstChild);

  await initUIController(shadowRoot, shadowHost);
}

async function initUIController(shadowRoot, shadowHost) {
  const $ = (sel, root = shadowRoot) => root.querySelector(sel);
  const $$ = (sel, root = shadowRoot) => Array.from(root.querySelectorAll(sel));

  let timeline = [];
  let mode = await GM.getValue('lyrics.mode', 'orig');
  let offsetMs = +(await GM.getValue('lyrics.offsetMs', 0));
  let fontSize = +(await GM.getValue('lyrics.fontSize', 28));
  shadowHost.style.setProperty('--font-size', fontSize + 'px');

  const panel = $('#lyric-float');

  const saveBounds = async () => {
    const r = panel.getBoundingClientRect();
    await GM.setValue('lyrics.bounds', JSON.stringify({
      x: r.left, y: r.top, w: r.width, h: r.height
    }));
  };

  const clampPanelToViewport = () => {
    const r = panel.getBoundingClientRect();
    const L = Math.max(0, Math.min(r.left, innerWidth - r.width));
    const T = Math.max(0, Math.min(r.top, innerHeight - r.height));
    if (r.left !== L) panel.style.left = L + 'px';
    if (r.top !== T) panel.style.top = T + 'px';
  };

  try {
    const savedBoundsStr = await GM.getValue('lyrics.bounds');
    const savedBounds = savedBoundsStr ? JSON.parse(savedBoundsStr) : null;
    if (savedBounds && savedBounds.w > 50 && savedBounds.h > 50) {
      panel.style.width = `${savedBounds.w}px`;
      panel.style.height = `${savedBounds.h}px`;
      panel.style.left = `${savedBounds.x}px`;
      panel.style.top = `${savedBounds.y}px`;
      requestAnimationFrame(clampPanelToViewport);
    }
  } catch (e) { console.error('[Lyrics] Error loading bounds', e); }

  const list = $('#lyric-list');
  const menu = $('#lyric-menu');
  const handle = $('#lyric-dragHandle');

  const renderList = () => {
    list.innerHTML = '';
    if (!timeline.length) return;
    timeline.forEach((row, i) => {
      const g = document.createElement('div'); g.className = 'group'; g.dataset.idx = i;
      const main = document.createElement('div'); main.className = 'line orig'; main.textContent = row.orig;
      g.appendChild(main);
      if (mode === 'orig+trans' && row.trans) {
        const sub = document.createElement('div'); sub.className = 'line sub'; sub.textContent = row.trans; g.appendChild(sub);
      } else if (mode === 'orig+roma' && row.roma) {
        const sub = document.createElement('div'); sub.className = 'line sub'; sub.textContent = row.roma; g.appendChild(sub);
      }
      list.appendChild(g);
    });
  };

  let lastActive = -1;
  const setActiveIndex = (idx) => {
    if (idx === lastActive) return;
    const prev = $(`.group[data-idx="${lastActive}"] .orig`, list);
    const next = $(`.group[data-idx="${idx}"] .orig`, list);
    prev?.classList.remove('active');
    next?.classList.add('active');
    lastActive = idx;
    if (next) {
      const listRect = list.getBoundingClientRect();
      const nextRect = next.parentElement.getBoundingClientRect();
      const isInView = nextRect.top >= listRect.top && nextRect.bottom <= listRect.bottom;
      if (!isInView) {
        list.scrollTo({ top: next.parentElement.offsetTop - list.clientHeight / 2 + next.clientHeight, behavior: 'smooth' });
      }
    }
  };

  const idxFor = (tEff) => {
    if (!timeline.length) return 0;
    let lo = 0, hi = timeline.length - 1, ans = 0;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (timeline[mid].t <= tEff) { ans = mid; lo = mid + 1; } else hi = mid - 1;
    }
    return ans;
  };

  const tick = (currentTimeMs) => {
    const eff = currentTimeMs + offsetMs;
    let i = idxFor(eff);
    setActiveIndex(i);
  };

  // --- Menu & Dragging Logic ---
  function openMenu(x, y, opts = {}) {
    const isCoarse = opts.pointerType ? (opts.pointerType === 'touch' || opts.pointerType === 'pen') : window.matchMedia('(pointer: coarse)').matches;
    menu.style.width = ''; menu.style.left = ''; menu.style.top = '';
    if (!isCoarse) { menu.style.left = x + 'px'; menu.style.top = y + 'px'; }
    refreshMenuUI();
    menu.classList.add('open');
    menu.setAttribute('aria-hidden', 'false');
    requestAnimationFrame(() => {
      if (!isCoarse) {
        const r = menu.getBoundingClientRect(); const vw = innerWidth, vh = innerHeight;
        const L = Math.min(r.left, vw - r.width - 8), T = Math.min(r.top, vh - r.height - 8);
        menu.style.left = Math.max(8, L) + 'px'; menu.style.top = Math.max(8, T) + 'px';
      }
    });
  }
  function closeMenu() { menu.classList.remove('open'); menu.setAttribute('aria-hidden', 'true'); }

  ;(() => { // Dragging
    let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, pointerId = null;
    const onPointerMove = (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX, dy = e.clientY - startY;
      let L = startLeft + dx, T = startTop + dy;
      const maxL = window.innerWidth - panel.offsetWidth, maxT = window.innerHeight - panel.offsetHeight;
      L = Math.max(0, Math.min(maxL, L)); T = Math.max(0, Math.min(maxT, T));
      panel.style.left = L + 'px'; panel.style.top = T + 'px';
    };
    const onPointerUp = async () => {
      if (!dragging) return;
      dragging = false;
      panel.classList.remove('dragging', 'no-select');
      if (pointerId !== null) { try { handle.releasePointerCapture(pointerId); } catch {} pointerId = null; }
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
      await saveBounds();
    };
    handle.addEventListener('pointerdown', (e) => {
      if (e.button !== 0) return;
      e.preventDefault(); closeMenu(); dragging = true;
      panel.classList.add('dragging', 'no-select');
      startX = e.clientX; startY = e.clientY;
      const rect = panel.getBoundingClientRect();
      startLeft = rect.left; startTop = rect.top;
      pointerId = e.pointerId;
      try { handle.setPointerCapture(pointerId); } catch {}
      window.addEventListener('pointermove', onPointerMove);
      window.addEventListener('pointerup', onPointerUp);
    });
  })();

  ;(() => { // Resize logic
    const resizeHandle = $('#lyric-resizeHandle');
    if (!resizeHandle) return;

    let resizing = false, startX = 0, startY = 0, startW = 0, startH = 0, pointerId = null;
    const onPointerMove = (e) => {
      if (!resizing) return;
      const dx = e.clientX - startX, dy = e.clientY - startY;
      panel.style.width = startW + dx + 'px';
      panel.style.height = startH + dy + 'px';
    };
    const onPointerUp = () => {
      if (!resizing) return;
      resizing = false;
      panel.classList.remove('no-select');
      if (pointerId !== null) { try { resizeHandle.releasePointerCapture(pointerId); } catch {} pointerId = null; }
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
    };
    resizeHandle.addEventListener('pointerdown', (e) => {
      if (e.button !== 0) return;
      e.preventDefault(); e.stopPropagation(); closeMenu(); resizing = true;
      panel.classList.add('no-select');
      startX = e.clientX; startY = e.clientY;
      const rect = panel.getBoundingClientRect();
      startW = rect.width; startH = rect.height;
      pointerId = e.pointerId;
      try { resizeHandle.setPointerCapture(pointerId); } catch {}
      window.addEventListener('pointermove', onPointerMove);
      window.addEventListener('pointerup', onPointerUp);
    });
  })();

  panel.addEventListener('contextmenu', (e) => { e.preventDefault(); openMenu(e.clientX, e.clientY, { pointerType: 'mouse' }); });
  ;(() => { // Long-press
    let pressTimer = 0, startX = 0, startY = 0, lastPointerType = 'mouse';
    const clearTimer = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = 0; } };
    const onDown = (e) => {
      if (e.button === 2 || (e.target && e.target.closest && e.target.closest('#lyric-dragHandle'))) return;
      lastPointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
      startX = e.clientX; startY = e.clientY;
      clearTimer();
      pressTimer = setTimeout(() => { openMenu(startX, startY, { pointerType: lastPointerType }); pressTimer = 0; }, 500);
    };
    const onMove = (e) => { if (pressTimer && Math.hypot(e.clientX - startX, e.clientY - startY) > 8) clearTimer(); };
    panel.addEventListener('pointerdown', onDown);
    panel.addEventListener('pointermove', onMove);
    ['pointerup', 'pointercancel', 'wheel'].forEach(evt => panel.addEventListener(evt, clearTimer, { passive: true }));
  })();

  window.addEventListener('click', (e) => {
    if (!menu.classList.contains('open')) return;
    if ((e.composedPath && e.composedPath() || []).includes(menu)) return;
    closeMenu();
  });
  window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });

  let resizeDebounceTimer;
  const onPanelResize = () => {
    clampPanelToViewport();
    clearTimeout(resizeDebounceTimer);
    resizeDebounceTimer = setTimeout(saveBounds, 200);
  };
  new ResizeObserver(onPanelResize).observe(panel);
  window.addEventListener('resize', clampPanelToViewport);

  // --- Settings Wiring ---
  const modeWrap = $('#lyric-modeWrap'), modeRadios = $$('input[name="mode"]', modeWrap);
  const sourceSelect = $('#lyric-sourceSelect'), fontMinus = $('#lyric-fontMinus'), fontPlus = $('#lyric-fontPlus');
  const fontSlider = $('#lyric-fontSlider'), fontValue = $('#lyric-fontValue');
  const offsetButtons = $('#lyric-offsetButtons'), offsetValue = $('#lyric-offsetValue');

  sourceSelect.addEventListener('change', () => {
    const selectedId = sourceSelect.value;
    if (selectedId) {
      lyricsUI.onSourceSelect(selectedId);
    }
  });

  const fmtOff = (ms) => (ms >= 0 ? '+' : '') + (ms / 1000).toFixed(2) + 's';
  const refreshOff = () => { if (offsetValue) offsetValue.textContent = fmtOff(offsetMs); };
  const refreshModeRadios = () => {
    $$('.radio', modeWrap).forEach(lab => {
      const inp = lab.querySelector('input');
      lab.classList.toggle('active', inp && inp.value === mode);
      if (inp) inp.checked = inp.value === mode;
    });
  };
  const applySize = async (v) => {
    fontSize = Math.max(12, Math.min(96, v | 0));
    shadowHost.style.setProperty('--font-size', fontSize + 'px');
    await GM.setValue('lyrics.fontSize', String(fontSize));
    if (fontSlider) fontSlider.value = String(fontSize);
    if (fontValue) fontValue.textContent = fontSize + 'px';
  };
  const refreshMenuUI = () => { refreshModeRadios(); refreshOff(); applySize(fontSize); };

  modeRadios.forEach(inp => inp.addEventListener('change', async () => {
    mode = inp.value; await GM.setValue('lyrics.mode', mode);
    refreshModeRadios(); renderList(); lastActive = -1;
  }));
  fontMinus?.addEventListener('click', () => applySize(fontSize - 2));
  fontPlus?.addEventListener('click', () => applySize(fontSize + 2));
  fontSlider?.addEventListener('input', () => applySize(+fontSlider.value));
  offsetButtons?.addEventListener('click', async (e) => {
    const btn = e.target.closest('button');
    if (!btn) return;
    if (btn.dataset.reset) offsetMs = 0;
    else if (btn.dataset.delta) offsetMs = offsetMs + (+btn.dataset.delta);
    await GM.setValue('lyrics.offsetMs', String(offsetMs));
    refreshOff(); lastActive = -1;
  });

  refreshMenuUI();

  // --- Public API ---
  const parseLrc = (txt = '') => txt.split(/\r?\n/).flatMap(line => {
    line = line.trim(); if (!line) return [];
    const m = line.match(/^\[(\d{2}):(\d{2})(?:[.:](\d{2,3}))?]\s*(.*)$/);
    if (m) {
      const [, mm, ss, ff = '0', text] = m;
      const sub = ff.length === 3 ? +ff : +ff * 10;
      const t = (+mm) * 60000 + (+ss) * 1000 + sub;
      return [{ t, text }];
    }
    try {
      const o = JSON.parse(line);
      if (Number.isFinite(o?.t) && Array.isArray(o?.c)) {
        return [{ t: +o.t, text: o.c.map(x => x?.tx || '').join('') }];
      }
    } catch {}
    return [];
  }).sort((a, b) => a.t - b.t);

  lyricsUI.updateSources = (sources) => {
    sourceSelect.innerHTML = '';
    if (!sources || sources.length === 0) {
      const opt = document.createElement('option');
      opt.textContent = 'No results';
      opt.disabled = true;
      sourceSelect.appendChild(opt);
      return;
    }
    sources.forEach((song) => {
      const opt = document.createElement('option');
      opt.value = song.resourceId;
      const artists = song.baseInfo.simpleSongData.ar?.map(a => a.name).join('/') || '';
      opt.textContent = `${song.baseInfo.simpleSongData.al.name}${artists ? ` - ${artists}` : ''}`;
      sourceSelect.appendChild(opt);
    });
  };

  lyricsUI.update = (lyricsData) => {
    const O = parseLrc(lyricsData.lrc?.lyric);
    const T = parseLrc(lyricsData.tlyric?.lyric);
    const R = parseLrc(lyricsData.romalrc?.lyric);
    const findNear = (arr, t, tol = 500) => {
      let lo = 0, hi = arr.length - 1, best = null;
      while (lo <= hi) {
        const mid = (lo + hi) >> 1;
        const dt = arr[mid].t - t;
        if (!best || Math.abs(dt) < Math.abs(best.t - t)) best = arr[mid];
        dt < 0 ? (lo = mid + 1) : (hi = mid - 1);
      }
      return best && Math.abs(best.t - t) <= tol ? best.text : '';
    };
    timeline = O.map(o => ({ t: o.t, orig: o.text, trans: findNear(T, o.t), roma: findNear(R, o.t) }));
    renderList();
    lastActive = -1;
    console.log('[Lyrics] Lyrics updated,', timeline.length, 'lines loaded');
  };

  lyricsUI.show = () => { panel.style.display = ''; };
  lyricsUI.hide = () => { panel.style.display = 'none'; };
  lyricsUI.tick = tick;
}

window.onunhandledrejection = console.error; // for debugging
if (window.unsafeWindow) { // for debugging
  unsafeWindow._cloudmusic = cloudmusic;
  unsafeWindow._lyricsUI = lyricsUI;
}

(async () => {
  'use strict';

  // Register anonymous device every 7 days
  const registerTime = await GM.getValue('lyrics.register.time', 0);
  const now = Date.now();
  if (now - registerTime > 7 * 24 * 60 * 60 * 1000) {
    try {
      const deviceId = '7B79802670C7A45DB9091976D71E0AE829E28926C6C34A1B8644'; // TODO: device ID generation
      console.log('[Lyrics] Registering new device ID:', deviceId);
      console.log('[Lyrics] Registration successful.', await cloudmusic.register(deviceId));
      await GM.setValue('lyrics.register.time', now);
    } catch (error) {
      console.error('[Lyrics] Registration failed:', error);
    }
  }

  // This script is injected into the page context to communicate with the userscript.
  const inlineScriptYTM = () => {
    setInterval(() => {
      const playerApi = document.querySelector('ytmusic-app')?.playerApi;
      if (!playerApi || !playerApi.getVideoData || !playerApi.getProgressState) return;

      const meta = playerApi.getVideoData();
      const progress = playerApi.getProgressState();
      const state = playerApi.getPlayerState();

      // Only send updates when a song is active (playing or paused)
      if (state !== 1 && state !== 2) {
        return;
      }

      const currentPlayerState = {
        videoId: meta.video_id,
        title: meta.title,
        author: meta.author,
        currentTime: progress.current
      };

      window.dispatchEvent(new CustomEvent('lyrics-player-update', { detail: currentPlayerState }));
    }, 500);
  };

  const inlineScriptSpotify = () => {
    console.log('[Lyrics] Spotify script injected');
    const parseTimeToSeconds = (timeStr) => {
      if (!timeStr) return 0;
      return timeStr.split(':').map(Number).reduce((acc, time) => (acc * 60) + time, 0);
    };

    setInterval(() => {
      const titleEl = document.querySelector('div[data-testid="context-item-info-title"]');
      const authorEl = document.querySelector('div[data-testid="context-item-info-subtitles"]');
      const timeEl = document.querySelector('div[data-testid="playback-position"]');
      const durationEl = document.querySelector('div[data-testid="playback-duration"]');

      if (!titleEl || !authorEl || !timeEl || !durationEl) return;

      const title = titleEl.textContent;
      const author = authorEl.textContent;
      const currentTime = parseTimeToSeconds(timeEl.textContent) + 1.5; // slight offset to compensate for delay
      const duration = parseTimeToSeconds(durationEl.textContent);

      if (!title || !author || duration === 0) return;

      const currentPlayerState = { videoId: `${title} - ${author}`, title, author, currentTime };
      window.dispatchEvent(new CustomEvent('lyrics-player-update', { detail: currentPlayerState }));
    }, 500);
  };

  // Inject the page script
  const injectPageScript = async (scriptFunc) => {
    let injected = false;
    window.addEventListener('lyrics-player-inject', () => { injected = true; }, { once: true });
    const textContent = `window.dispatchEvent(new CustomEvent('lyrics-player-inject'));(${scriptFunc})();`;
    if (typeof GM !== 'undefined' && typeof GM.addElement === 'function') {
      GM.addElement('script', { textContent })
    } else {
      try {
        const script = document.createElement("script");
        script.textContent = textContent;
        (document.body ?? document.head ?? document.documentElement).append(script);
      } catch (error) {
        console.warn("[Lyrics] Failed to inject page script:", error);
      }
    }

    await new Promise((resolve) => setTimeout(resolve, 100));
    if (injected) {
      console.log('[Lyrics] Page script injected successfully');
      return;
    }

    console.warn("[Lyrics] Page script injection failed, falling back to eval");
    eval(textContent);
  };

  await injectUI();

  if (window.location.hostname.indexOf('youtube') >= 0) {
    await injectPageScript(inlineScriptYTM);
  } else if (window.location.hostname.indexOf('spotify') >= 0) {
    await injectPageScript(inlineScriptSpotify);
  }

  const fetchLyricsForId = async (id) => {
    try {
      console.log(`[Lyrics] Fetching lyrics for ID: ${id}`);
      const lyricData = await cloudmusic.lyric(id);
      if (!lyricData.lrc?.lyric) throw new Error('No lyrics found for this ID');
      lyricsUI.update(lyricData);
      lyricsUI.show();
    } catch (error) {
      console.error('[Lyrics] Error fetching selected lyrics:', error);
      lyricsUI.hide();
    }
  };

  lyricsUI.onSourceSelect = fetchLyricsForId;

  // Main logic driven by events from the page script
  let lastVideoId = null;
  window.addEventListener('lyrics-player-update', async (event) => {
    const { videoId, title, author, currentTime } = event.detail;

    // Update lyrics if song has changed
    if (videoId && videoId !== lastVideoId) {
      lastVideoId = videoId;
      console.log(`[Lyrics] New song detected: ${title} by ${author}`);
      try {
        const keyword = `${title} ${author}`;
        const results = await cloudmusic.search(keyword);
        const searchResults = results?.data?.resources || [];
        lyricsUI.updateSources(searchResults);

        if (searchResults.length > 0) {
          console.log(`[Lyrics] Found ${searchResults.length} results for "${keyword}"`, searchResults);
          const firstSongId = searchResults[0].resourceId;
          await fetchLyricsForId(firstSongId);
        } else {
          console.log(`[Lyrics] No results found for "${keyword}"`);
          lyricsUI.hide();
        }
      } catch (error) {
        console.error('[Lyrics] Error fetching lyrics:', error);
        lyricsUI.updateSources([]);
        lyricsUI.hide();
      }
    }

    lyricsUI.tick(currentTime * 1000);
  });

  console.log('[Lyrics] initialized');
})();